diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 7b44d70315..cb8c4c23d0 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -1,19 +1,37 @@ String cron_string = BRANCH_NAME == "master" ? "@daily" : "" pipeline { - + agent any - + environment { HOME = "/tmp" + GIT_PROJECT = "web-sharelatex-internal" + JENKINS_WORKFLOW = "web-sharelatex-internal" + TARGET_URL = "${env.JENKINS_URL}blue/organizations/jenkins/${JENKINS_WORKFLOW}/detail/$BRANCH_NAME/$BUILD_NUMBER/pipeline" + GIT_API_URL = "https://api.github.com/repos/sharelatex/${GIT_PROJECT}/statuses/$GIT_COMMIT" } - + triggers { pollSCM('* * * * *') cron(cron_string) } - + stages { + stage('Pre') { + steps { + withCredentials([usernamePassword(credentialsId: 'GITHUB_INTEGRATION', usernameVariable: 'GH_AUTH_USERNAME', passwordVariable: 'GH_AUTH_PASSWORD')]) { + sh "curl $GIT_API_URL \ + --data '{ \ + \"state\" : \"pending\", \ + \"target_url\": \"$TARGET_URL\", \ + \"description\": \"Your build is underway\", \ + \"context\": \"ci/jenkins\" }' \ + -u $GH_AUTH_USERNAME:$GH_AUTH_PASSWORD" + } + } + } + stage('Install modules') { steps { sshagent (credentials: ['GIT_DEPLOY_KEY']) { @@ -21,7 +39,7 @@ pipeline { } } } - + stage('Install') { agent { docker { @@ -66,16 +84,39 @@ pipeline { sh 'make --no-print-directory lint' } } - - stage('Unit Test') { - agent { - docker { - image 'node:6.9.5' - reuseNode true + + stage('Test and Minify') { + parallel { + stage('Unit Test') { + agent { + docker { + image 'node:6.9.5' + reuseNode true + } + } + steps { + sh 'make --no-print-directory test_unit MOCHA_ARGS="--reporter tap"' + } + } + + stage('Acceptance Test') { + steps { + // Spawns its own docker containers + sh 'make --no-print-directory test_acceptance MOCHA_ARGS="--reporter tap"' + } + } + + stage('Minify') { + agent { + docker { + image 'node:6.9.5' + reuseNode true + } + } + steps { + sh 'WEBPACK_ENV=production make minify' + } } - } - steps { - sh 'make --no-print-directory test_unit MOCHA_ARGS="--reporter tap"' } } @@ -85,26 +126,7 @@ pipeline { sh 'make --no-print-directory test_frontend' } } - - stage('Acceptance Test') { - steps { - // Spawns its own docker containers - sh 'make --no-print-directory test_acceptance MOCHA_ARGS="--reporter tap"' - } - } - stage('Minify') { - agent { - docker { - image 'node:6.9.5' - reuseNode true - } - } - steps { - sh 'WEBPACK_ENV=production make minify' - } - } - stage('Package') { steps { sh 'rm -rf ./node_modules/grunt*' @@ -113,7 +135,7 @@ pipeline { sh 'tar -czf build.tar.gz --exclude=build.tar.gz --exclude-vcs .' } } - + stage('Publish') { steps { withAWS(credentials:'S3_CI_BUILDS_AWS_KEYS', region:"${S3_REGION_BUILD_ARTEFACTS}") { @@ -123,8 +145,8 @@ pipeline { } } } - - + + stage('Sync OSS') { when { branch 'master' @@ -136,29 +158,50 @@ pipeline { } } } - + post { always { sh 'make clean_ci' } + success { + withCredentials([usernamePassword(credentialsId: 'GITHUB_INTEGRATION', usernameVariable: 'GH_AUTH_USERNAME', passwordVariable: 'GH_AUTH_PASSWORD')]) { + sh "curl $GIT_API_URL \ + --data '{ \ + \"state\" : \"success\", \ + \"target_url\": \"$TARGET_URL\", \ + \"description\": \"Your build succeeded!\", \ + \"context\": \"ci/jenkins\" }' \ + -u $GH_AUTH_USERNAME:$GH_AUTH_PASSWORD" + } + } + failure { - mail(from: "${EMAIL_ALERT_FROM}", - to: "${EMAIL_ALERT_TO}", + mail(from: "${EMAIL_ALERT_FROM}", + to: "${EMAIL_ALERT_TO}", subject: "Jenkins build failed: ${JOB_NAME}:${BUILD_NUMBER}", body: "Build: ${BUILD_URL}") + withCredentials([usernamePassword(credentialsId: 'GITHUB_INTEGRATION', usernameVariable: 'GH_AUTH_USERNAME', passwordVariable: 'GH_AUTH_PASSWORD')]) { + sh "curl $GIT_API_URL \ + --data '{ \ + \"state\" : \"failure\", \ + \"target_url\": \"$TARGET_URL\", \ + \"description\": \"Your build failed\", \ + \"context\": \"ci/jenkins\" }' \ + -u $GH_AUTH_USERNAME:$GH_AUTH_PASSWORD" + } } } - + // The options directive is for configuration that applies to the whole job. options { // Only build one at a time disableConcurrentBuilds() - + // we'd like to make sure remove old builds, so we don't fill up our storage! buildDiscarder(logRotator(numToKeepStr:'50')) - + // And we'd really like to be sure that this build doesn't hang forever, so let's time it out after: timeout(time: 30, unit: 'MINUTES') } diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee index dfa19ed8a8..d7e9ed4bc6 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee @@ -1,6 +1,5 @@ AuthenticationManager = require ("./AuthenticationManager") LoginRateLimiter = require("../Security/LoginRateLimiter") -UserGetter = require "../User/UserGetter" UserUpdater = require "../User/UserUpdater" Metrics = require('metrics-sharelatex') logger = require("logger-sharelatex") @@ -62,41 +61,63 @@ module.exports = AuthenticationController = if err? return next(err) if user # `user` is either a user object or false - redir = AuthenticationController._getRedirectFromSession(req) || "/project" - AuthenticationController.afterLoginSessionSetup req, user, (err) -> - if err? - return next(err) - AuthenticationController._clearRedirectFromSession(req) - res.json {redir: redir} + AuthenticationController.finishLogin(user, req, res, next) else - res.json message: info + if info.redir? + res.json {redir: info.redir} + else + res.json message: info )(req, res, next) + finishLogin: (user, req, res, next) -> + redir = AuthenticationController._getRedirectFromSession(req) || "/project" + AuthenticationController._loginAsyncHandlers(req, user) + AuthenticationController.afterLoginSessionSetup req, user, (err) -> + if err? + return next(err) + AuthenticationController._clearRedirectFromSession(req) + if req.headers?['accept']?.match(/^application\/json.*$/) + res.json {redir: redir} + else + res.redirect(redir) + doPassportLogin: (req, username, password, done) -> email = username.toLowerCase() - LoginRateLimiter.processLoginRequest email, (err, isAllowed)-> - return done(err) if err? - if !isAllowed - logger.log email:email, "too many login requests" - return done(null, null, {text: req.i18n.translate("to_many_login_requests_2_mins"), type: 'error'}) - AuthenticationManager.authenticate email: email, password, (error, user) -> - return done(error) if error? - if user? - # async actions - UserHandler.setupLoginData(user, ()->) - LoginRateLimiter.recordSuccessfulLogin(email) - AuthenticationController._recordSuccessfulLogin(user._id) - Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip}) - Analytics.identifyUser(user._id, req.sessionID) - logger.log email: email, user_id: user._id.toString(), "successful log in" - req.session.justLoggedIn = true - # capture the request ip for use when creating the session - user._login_req_ip = req.ip - return done(null, user) - else - AuthenticationController._recordFailedLogin() - logger.log email: email, "failed log in" - return done(null, false, {text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error'}) + Modules = require "../../infrastructure/Modules" + Modules.hooks.fire 'preDoPassportLogin', req, email, (err, infoList) -> + return next(err) if err? + info = infoList.find((i) => i?) + if info? + return done(null, false, info) + LoginRateLimiter.processLoginRequest email, (err, isAllowed)-> + return done(err) if err? + if !isAllowed + logger.log email:email, "too many login requests" + return done(null, null, {text: req.i18n.translate("to_many_login_requests_2_mins"), type: 'error'}) + AuthenticationManager.authenticate email: email, password, (error, user) -> + return done(error) if error? + if user? + # async actions + return done(null, user) + else + AuthenticationController._recordFailedLogin() + logger.log email: email, "failed log in" + return done( + null, + false, + {text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error'} + ) + + _loginAsyncHandlers: (req, user) -> + UserHandler.setupLoginData(user, ()->) + LoginRateLimiter.recordSuccessfulLogin(user.email) + AuthenticationController._recordSuccessfulLogin(user._id) + Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip}) + Analytics.identifyUser(user._id, req.sessionID) + logger.log email: user.email, user_id: user._id.toString(), "successful log in" + req.session.justLoggedIn = true + # capture the request ip for use when creating the session + user._login_req_ip = req.ip setInSessionUser: (req, props) -> for key, value of props diff --git a/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee b/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee index 2bee2ba5e6..f2c982a077 100644 --- a/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee +++ b/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee @@ -49,7 +49,7 @@ module.exports = (backendGroup)-> return callback() serverId = @_parseServerIdFromResponse(response) if !serverId? # We don't get a cookie back if it hasn't changed - return callback() + return rclient.expire(@buildKey(project_id), Settings.clsiCookie.ttl, callback) if rclient_secondary? @_setServerIdInRedis rclient_secondary, project_id, serverId @_setServerIdInRedis rclient, project_id, serverId, (err) -> diff --git a/services/web/app/coffee/Features/Compile/ClsiManager.coffee b/services/web/app/coffee/Features/Compile/ClsiManager.coffee index c62e2d570d..b0f7a01fa1 100755 --- a/services/web/app/coffee/Features/Compile/ClsiManager.coffee +++ b/services/web/app/coffee/Features/Compile/ClsiManager.coffee @@ -7,8 +7,8 @@ ProjectGetter = require("../Project/ProjectGetter") ProjectEntityHandler = require("../Project/ProjectEntityHandler") logger = require "logger-sharelatex" Url = require("url") -ClsiCookieManager = require("./ClsiCookieManager")() -NewBackendCloudClsiCookieManager = require("./ClsiCookieManager")("newBackendcloud") +ClsiCookieManager = require("./ClsiCookieManager")(Settings.apis.clsi?.backendGroupName) +NewBackendCloudClsiCookieManager = require("./ClsiCookieManager")(Settings.apis.clsi_new?.backendGroupName) ClsiStateManager = require("./ClsiStateManager") _ = require("underscore") async = require("async") @@ -100,6 +100,7 @@ module.exports = ClsiManager = timer = new Metrics.Timer("compile.currentBackend") request opts, (err, response, body)-> timer.done() + Metrics.inc "compile.currentBackend.response.#{response?.statusCode}" if err? logger.err err:err, project_id:project_id, url:opts?.url, "error making request to clsi" return callback(err) @@ -111,13 +112,14 @@ module.exports = ClsiManager = newBackend: (cb)-> startTime = new Date() ClsiManager._makeNewBackendRequest project_id, opts, (err, response, body)-> + Metrics.inc "compile.newBackend.response.#{response?.statusCode}" cb(err, {response:response, body:body, finishTime:new Date() - startTime}) }, (err, results)-> timeDifference = results.newBackend?.finishTime - results.currentBackend?.finishTime statusCodeSame = results.newBackend?.response?.statusCode == results.currentBackend?.response?.statusCode currentCompileTime = results.currentBackend?.finishTime newBackendCompileTime = results.newBackend?.finishTime - logger.log {statusCodeSame, timeDifference, currentCompileTime, newBackendCompileTime}, "both clsi requests returned" + logger.log {statusCodeSame, timeDifference, currentCompileTime, newBackendCompileTime, project_id}, "both clsi requests returned" diff --git a/services/web/app/coffee/Features/Compile/CompileController.coffee b/services/web/app/coffee/Features/Compile/CompileController.coffee index d79eb5d389..8a3f92ac66 100755 --- a/services/web/app/coffee/Features/Compile/CompileController.coffee +++ b/services/web/app/coffee/Features/Compile/CompileController.coffee @@ -9,7 +9,7 @@ Settings = require "settings-sharelatex" AuthenticationController = require "../Authentication/AuthenticationController" UserGetter = require "../User/UserGetter" RateLimiter = require("../../infrastructure/RateLimiter") -ClsiCookieManager = require("./ClsiCookieManager")() +ClsiCookieManager = require("./ClsiCookieManager")(Settings.apis.clsi?.backendGroupName) Path = require("path") module.exports = CompileController = diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 846042312d..7dad02bde9 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -163,6 +163,13 @@ module.exports = EditorController = EditorRealTimeController.emitToRoom project_id, 'compilerUpdated', compiler callback() + setImageName : (project_id, imageName, callback = (err) ->) -> + ProjectOptionsHandler.setImageName project_id, imageName, (err) -> + return callback(err) if err? + logger.log imageName:imageName, project_id:project_id, "setting imageName" + EditorRealTimeController.emitToRoom project_id, 'imageNameUpdated', imageName + callback() + setSpellCheckLanguage : (project_id, languageCode, callback = (err) ->) -> ProjectOptionsHandler.setSpellCheckLanguage project_id, languageCode, (err) -> return callback(err) if err? diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee index 25b3f3e06d..f33f3e523e 100644 --- a/services/web/app/coffee/Features/Errors/Errors.coffee +++ b/services/web/app/coffee/Features/Errors/Errors.coffee @@ -68,6 +68,20 @@ V1ConnectionError = (message) -> return error V1ConnectionError.prototype.__proto___ = Error.prototype +UnconfirmedEmailError = (message) -> + error = new Error(message) + error.name = "UnconfirmedEmailError" + error.__proto__ = UnconfirmedEmailError.prototype + return error +UnconfirmedEmailError.prototype.__proto___ = Error.prototype + +EmailExistsError = (message) -> + error = new Error(message) + error.name = "EmailExistsError" + error.__proto__ = EmailExistsError.prototype + return error +EmailExistsError.prototype.__proto___ = Error.prototype + module.exports = Errors = NotFoundError: NotFoundError ServiceNotConfiguredError: ServiceNotConfiguredError @@ -79,3 +93,5 @@ module.exports = Errors = V1HistoryNotSyncedError: V1HistoryNotSyncedError ProjectHistoryDisabledError: ProjectHistoryDisabledError V1ConnectionError: V1ConnectionError + UnconfirmedEmailError: UnconfirmedEmailError + EmailExistsError: EmailExistsError diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee index 7c74d14aed..e724951d2d 100644 --- a/services/web/app/coffee/Features/Exports/ExportsController.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee @@ -37,3 +37,11 @@ module.exports = status_detail: parsed_export.status_detail } res.send export_json: json + + exportZip: (req, res) -> + {export_id} = req.params + AuthenticationController.getLoggedInUserId(req) + ExportsHandler.fetchZip export_id, (err, export_zip_url) -> + return err if err? + + res.redirect export_zip_url diff --git a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee index 17a0798ead..234a334241 100644 --- a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee @@ -57,6 +57,9 @@ module.exports = ExportsHandler = self = historyId: project.overleaf?.history?.id historyVersion: historyVersion v1ProjectId: project.overleaf?.id + metadata: + compiler: project.compiler + imageName: project.imageName user: id: user_id firstName: user.first_name @@ -115,3 +118,19 @@ module.exports = ExportsHandler = self = err = new Error("v1 export returned a failure status code: #{res.statusCode}") logger.err err:err, export:export_id, "v1 export returned failure status code: #{res.statusCode}" callback err + + fetchZip: (export_id, callback=(err, zip_url) ->) -> + console.log("#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url") + request.get { + url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url" + auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass } + }, (err, res, body) -> + if err? + logger.err err:err, export:export_id, "error making request to v1 export" + callback err + else if 200 <= res.statusCode < 300 + callback null, body + else + err = new Error("v1 export returned a failure status code: #{res.statusCode}") + logger.err err:err, export:export_id, "v1 export zip fetch returned failure status code: #{res.statusCode}" + callback err diff --git a/services/web/app/coffee/Features/History/HistoryController.coffee b/services/web/app/coffee/Features/History/HistoryController.coffee index 7d9d347531..189e07b0f5 100644 --- a/services/web/app/coffee/Features/History/HistoryController.coffee +++ b/services/web/app/coffee/Features/History/HistoryController.coffee @@ -21,6 +21,13 @@ module.exports = HistoryController = req.useProjectHistory = false next() + ensureProjectHistoryEnabled: (req, res, next = (error) ->) -> + if req.useProjectHistory? + next() + else + logger.log {project_id}, "project history not enabled" + res.sendStatus(404) + proxyToHistoryApi: (req, res, next = (error) ->) -> user_id = AuthenticationController.getLoggedInUserId req url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url @@ -41,22 +48,17 @@ module.exports = HistoryController = user_id = AuthenticationController.getLoggedInUserId req url = HistoryController.buildHistoryServiceUrl(req.useProjectHistory) + req.url logger.log url: url, "proxying to history api" - request { + HistoryController._makeRequest { url: url method: req.method json: true headers: "X-User-Id": user_id - }, (error, response, body) -> + }, (error, body) -> return next(error) if error? - if 200 <= response.statusCode < 300 - HistoryManager.injectUserDetails body, (error, data) -> - return next(error) if error? - res.json data - else - error = new Error("history api responded with non-success code: #{response.statusCode}") - logger.error err: error, user_id: user_id, "error proxying request to history api" - next(error) + HistoryManager.injectUserDetails body, (error, data) -> + return next(error) if error? + res.json data buildHistoryServiceUrl: (useProjectHistory) -> # choose a history service, either document-level (trackchanges) @@ -98,3 +100,46 @@ module.exports = HistoryController = doc_id: doc._id } + getLabels: (req, res, next) -> + project_id = req.params.Project_id + user_id = AuthenticationController.getLoggedInUserId(req) + HistoryController._makeRequest { + method: "GET" + url: "#{settings.apis.project_history.url}/project/#{project_id}/labels" + json: true + }, (error, labels) -> + return next(error) if error? + res.json labels + + createLabel: (req, res, next) -> + project_id = req.params.Project_id + {comment, version} = req.body + user_id = AuthenticationController.getLoggedInUserId(req) + HistoryController._makeRequest { + method: "POST" + url: "#{settings.apis.project_history.url}/project/#{project_id}/user/#{user_id}/labels" + json: {comment, version} + }, (error, label) -> + return next(error) if error? + res.json label + + deleteLabel: (req, res, next) -> + project_id = req.params.Project_id + label_id = req.params.label_id + user_id = AuthenticationController.getLoggedInUserId(req) + HistoryController._makeRequest { + method: "DELETE" + url: "#{settings.apis.project_history.url}/project/#{project_id}/user/#{user_id}/labels/#{label_id}" + }, (error) -> + return next(error) if error? + res.sendStatus 204 + + _makeRequest: (options, callback) -> + request options, (error, response, body) -> + return callback(error) if error? + if 200 <= response.statusCode < 300 + callback(null, body) + else + error = new Error("history api responded with non-success code: #{response.statusCode}") + logger.error err: error, "project-history api responded with non-success code: #{response.statusCode}" + callback(error) diff --git a/services/web/app/coffee/Features/User/UserAffiliationsManager.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee similarity index 80% rename from services/web/app/coffee/Features/User/UserAffiliationsManager.coffee rename to services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee index 77dad7a8ef..c62a8a01bc 100644 --- a/services/web/app/coffee/Features/User/UserAffiliationsManager.coffee +++ b/services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee @@ -3,12 +3,20 @@ metrics = require("metrics-sharelatex") settings = require "settings-sharelatex" request = require "request" -module.exports = UserAffiliationsManager = - getAffiliations: (userId, callback = (error, body) ->) -> +module.exports = InstitutionsAPI = + getInstitutionAffiliations: (institutionId, callback = (error, body) ->) -> + makeAffiliationRequest { + method: 'GET' + path: "/api/v2/institutions/#{institutionId.toString()}/affiliations" + defaultErrorMessage: "Couldn't get institution affiliations" + }, callback + + + getUserAffiliations: (userId, callback = (error, body) ->) -> makeAffiliationRequest { method: 'GET' path: "/api/v2/users/#{userId.toString()}/affiliations" - defaultErrorMessage: "Couldn't get affiliations" + defaultErrorMessage: "Couldn't get user affiliations" }, callback @@ -72,15 +80,18 @@ makeAffiliationRequest = (requestOptions, callback = (error) ->) -> errorMessage = "#{response.statusCode}: #{body.errors}" else errorMessage = "#{requestOptions.defaultErrorMessage}: #{response.statusCode}" + + logger.err path: requestOptions.path, body: requestOptions.body, errorMessage return callback(new Error(errorMessage)) callback(null, body) [ - 'getAffiliations', + 'getInstitutionAffiliations' + 'getUserAffiliations', 'addAffiliation', 'removeAffiliation', ].map (method) -> metrics.timeAsyncMethod( - UserAffiliationsManager, method, 'mongo.UserAffiliationsManager', logger + InstitutionsAPI, method, 'mongo.InstitutionsAPI', logger ) diff --git a/services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee new file mode 100644 index 0000000000..4ce233406b --- /dev/null +++ b/services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee @@ -0,0 +1,22 @@ +InstitutionsGetter = require './InstitutionsGetter' +PlansLocator = require '../Subscription/PlansLocator' +Settings = require 'settings-sharelatex' +logger = require 'logger-sharelatex' + +module.exports = InstitutionsFeatures = + getInstitutionsFeatures: (userId, callback = (error, features) ->) -> + InstitutionsFeatures.hasLicence userId, (error, hasLicence) -> + return callback error if error? + return callback(null, {}) unless hasLicence + plan = PlansLocator.findLocalPlanInSettings Settings.institutionPlanCode + callback(null, plan?.features or {}) + + + hasLicence: (userId, callback = (error, hasLicence) ->) -> + InstitutionsGetter.getConfirmedInstitutions userId, (error, institutions) -> + return callback error if error? + + hasLicence = institutions.some (institution) -> + institution.licence and institution.licence != 'free' + + callback(null, hasLicence) diff --git a/services/web/app/coffee/Features/Institutions/InstitutionsGetter.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsGetter.coffee new file mode 100644 index 0000000000..e5941dc777 --- /dev/null +++ b/services/web/app/coffee/Features/Institutions/InstitutionsGetter.coffee @@ -0,0 +1,14 @@ +UserGetter = require '../User/UserGetter' +logger = require 'logger-sharelatex' + +module.exports = InstitutionsGetter = + getConfirmedInstitutions: (userId, callback = (error, institutions) ->) -> + UserGetter.getUserFullEmails userId, (error, emailsData) -> + return callback error if error? + + confirmedInstitutions = emailsData.filter (emailData) -> + emailData.confirmedAt? and emailData.affiliation?.institution? + .map (emailData) -> + emailData.affiliation?.institution + + callback(null, confirmedInstitutions) diff --git a/services/web/app/coffee/Features/Institutions/InstitutionsManager.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsManager.coffee new file mode 100644 index 0000000000..15233c8849 --- /dev/null +++ b/services/web/app/coffee/Features/Institutions/InstitutionsManager.coffee @@ -0,0 +1,17 @@ +logger = require 'logger-sharelatex' +async = require 'async' +db = require("../../infrastructure/mongojs").db +ObjectId = require("../../infrastructure/mongojs").ObjectId +{ getInstitutionAffiliations } = require('./InstitutionsAPI') +FeaturesUpdater = require('../Subscription/FeaturesUpdater') + +ASYNC_LIMIT = 10 +module.exports = InstitutionsManager = + upgradeInstitutionUsers: (institutionId, callback = (error) ->) -> + getInstitutionAffiliations institutionId, (error, affiliations) -> + return callback(error) if error + async.eachLimit affiliations, ASYNC_LIMIT, refreshFeatures, callback + +refreshFeatures = (affiliation, callback) -> + userId = ObjectId(affiliation.user_id) + FeaturesUpdater.refreshFeatures(userId, true, callback) diff --git a/services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee b/services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee index 8d7eee43bf..5fb2e00eb7 100644 --- a/services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee +++ b/services/web/app/coffee/Features/Newsletter/NewsletterManager.coffee @@ -1,37 +1,68 @@ async = require('async') -Request = require('request') logger = require 'logger-sharelatex' Settings = require 'settings-sharelatex' +crypto = require('crypto') +Mailchimp = require('mailchimp-api-v3') + +if !Settings.mailchimp?.api_key? + logger.info "Using newsletter provider: none" + mailchimp = + request: (opts, cb)-> cb() +else + logger.info "Using newsletter provider: mailchimp" + mailchimp = new Mailchimp(Settings.mailchimp?.api_key) module.exports = + subscribe: (user, callback = () ->)-> - if !Settings.markdownmail? - logger.warn "No newsletter provider configured so not subscribing user" - return callback() - logger.log user:user, email:user.email, "trying to subscribe user to the mailing list" options = buildOptions(user, true) - Request.post options, (err, response, body)-> - logger.log body:body, user:user, "finished attempting to subscribe the user to the news letter" + logger.log options:options, user:user, email:user.email, "trying to subscribe user to the mailing list" + mailchimp.request options, (err)-> + if err? + logger.err err:err, "error subscribing person to newsletter" + else + logger.log user:user, "finished subscribing user to the newsletter" callback(err) unsubscribe: (user, callback = () ->)-> - if !Settings.markdownmail? - logger.warn "No newsletter provider configured so not unsubscribing user" - return callback() logger.log user:user, email:user.email, "trying to unsubscribe user to the mailing list" options = buildOptions(user, false) - Request.post options, (err, response, body)-> - logger.log err:err, body:body, email:user.email, "compled newsletter unsubscribe attempt" + mailchimp.request options, (err)-> + if err? + logger.err err:err, "error unsubscribing person to newsletter" + else + logger.log user:user, "finished unsubscribing user to the newsletter" callback(err) + changeEmail: (oldEmail, newEmail, callback = ()->)-> + options = buildOptions({email:oldEmail}) + delete options.body.status + options.body.email_address = newEmail + mailchimp.request options, (err)-> + # if the user has unsubscribed mailchimp will error on email address change + if err? and err?.message.indexOf("could not be validated") == -1 + logger.err err:err, "error changing email in newsletter" + return callback(err) + else + logger.log "finished changing email in the newsletter" + return callback() + +hashEmail = (email)-> + crypto.createHash('md5').update(email.toLowerCase()).digest("hex") + buildOptions = (user, is_subscribed)-> - options = - json: - secret_token: Settings.markdownmail.secret - name: "#{user.first_name} #{user.last_name}" - email: user.email - subscriber_list_id: Settings.markdownmail.list_id - is_subscribed: is_subscribed - url: "https://www.markdownmail.io/lists/subscribe" - timeout: 30 * 1000 - return options \ No newline at end of file + status = if is_subscribed then "subscribed" else "unsubscribed" + subscriber_hash = hashEmail(user.email) + opts = + method: "PUT" + path: "/lists/#{Settings.mailchimp?.list_id}/members/#{subscriber_hash}" + body: + status_if_new: status + status: status + email_address:user.email + merge_fields: + FNAME: user.first_name + LNAME: user.last_name + MONGO_ID:user._id + return opts + diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index e2c71a956e..f3d12bf5c5 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -28,6 +28,7 @@ Modules = require '../../infrastructure/Modules' ProjectEntityHandler = require './ProjectEntityHandler' crypto = require 'crypto' { V1ConnectionError } = require '../Errors/Errors' +Features = require('../../infrastructure/Features') module.exports = ProjectController = @@ -48,6 +49,10 @@ module.exports = ProjectController = jobs.push (callback) -> editorController.setCompiler project_id, req.body.compiler, callback + if req.body.imageName? + jobs.push (callback) -> + editorController.setImageName project_id, req.body.imageName, callback + if req.body.name? jobs.push (callback) -> editorController.renameProject project_id, req.body.name, callback @@ -344,9 +349,9 @@ module.exports = ProjectController = themes: THEME_LIST maxDocLength: Settings.max_doc_length useV2History: !!project.overleaf?.history?.display - showRichText: req.query?.rt == 'true' + richTextEnabled: Features.hasFeature('rich-text') showTestControls: req.query?.tc == 'true' || user.isAdmin - showPublishModal: req.query?.pm == 'true' + allowedImageNames: Settings.allowedImageNames || [] timer.done() _buildProjectList: (allProjects, v1Projects = [])-> diff --git a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee index 529164a49e..f83d6e7d21 100644 --- a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee @@ -55,7 +55,8 @@ module.exports = ProjectCreationHandler = if Settings.apis?.project_history?.displayHistoryForNewProjects project.overleaf.history.display = true if Settings.currentImageName? - project.imageName = Settings.currentImageName + # avoid clobbering any imageName already set in attributes (e.g. importedImageName) + project.imageName ?= Settings.currentImageName project.rootFolder[0] = rootFolder User.findById owner_id, "ace.spellCheckLanguage", (err, user)-> project.spellCheckLanguage = user.ace.spellCheckLanguage diff --git a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee index 410dbf0351..f81c59c399 100644 --- a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee @@ -1,5 +1,5 @@ _ = require("underscore") - +Path = require 'path' module.exports = ProjectEditorHandler = trackChangesAvailable: false @@ -20,6 +20,7 @@ module.exports = ProjectEditorHandler = members: [] invites: invites tokens: project.tokens + imageName: if project.imageName? then Path.basename(project.imageName) else undefined if !result.invites? result.invites = [] diff --git a/services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee b/services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee index 0a0b02e40e..456b164683 100644 --- a/services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectOptionsHandler.coffee @@ -17,6 +17,16 @@ module.exports = if callback? callback() + setImageName : (project_id, imageName, callback = ()->)-> + logger.log project_id:project_id, imageName:imageName, "setting the imageName" + imageName = imageName.toLowerCase() + if ! _.some(settings.allowedImageNames, (allowed) -> imageName is allowed.imageName) + return callback() + conditions = {_id:project_id} + update = {imageName: settings.imageRoot + '/' + imageName} + Project.update conditions, update, {}, (err)-> + if callback? + callback() setSpellCheckLanguage: (project_id, languageCode, callback = ()->)-> logger.log project_id:project_id, languageCode:languageCode, "setting the spell check language" diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee index 5627072c93..ef5a6c4bb2 100644 --- a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee @@ -7,6 +7,7 @@ Settings = require("settings-sharelatex") logger = require("logger-sharelatex") ReferalFeatures = require("../Referal/ReferalFeatures") V1SubscriptionManager = require("./V1SubscriptionManager") +InstitutionsFeatures = require '../Institutions/InstitutionsFeatures' oneMonthInSeconds = 60 * 60 * 24 * 30 @@ -21,9 +22,11 @@ module.exports = FeaturesUpdater = if error? logger.err {err: error, user_id}, "error notifying v1 about updated features" + jobs = individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb groupFeatureSets: (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb + institutionFeatures:(cb) -> InstitutionsFeatures.getInstitutionsFeatures user_id, cb v1Features: (cb) -> FeaturesUpdater._getV1Features user_id, cb bonusFeatures: (cb) -> ReferalFeatures.getBonusFeatures user_id, cb async.series jobs, (err, results)-> @@ -32,9 +35,9 @@ module.exports = FeaturesUpdater = "error getting subscription or group for refreshFeatures" return callback(err) - {individualFeatures, groupFeatureSets, v1Features, bonusFeatures} = results - logger.log {user_id, individualFeatures, groupFeatureSets, v1Features, bonusFeatures}, 'merging user features' - featureSets = groupFeatureSets.concat [individualFeatures, v1Features, bonusFeatures] + {individualFeatures, groupFeatureSets, institutionFeatures, v1Features, bonusFeatures} = results + logger.log {user_id, individualFeatures, groupFeatureSets, institutionFeatures, v1Features, bonusFeatures}, 'merging user features' + featureSets = groupFeatureSets.concat [individualFeatures, institutionFeatures, v1Features, bonusFeatures] features = _.reduce(featureSets, FeaturesUpdater._mergeFeatures, Settings.defaultFeatures) logger.log {user_id, features}, 'updating user features' diff --git a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee index a23372903b..078676086b 100644 --- a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee +++ b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee @@ -19,7 +19,7 @@ module.exports = LimitationsManager = if user.features? and user.features.collaborators? callback null, user.features.collaborators else - callback null, Settings.defaultPlanCode.collaborators + callback null, Settings.defaultFeatures.collaborators canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) -> @allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) => @@ -89,13 +89,13 @@ module.exports = LimitationsManager = return currentTotal >= subscription.membersLimit - hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, subscription)->)-> - SubscriptionLocator.getUsersSubscription user_id, (err, subscription)-> + hasGroupMembersLimitReached: (subscriptionId, callback = (err, limitReached, subscription)->)-> + SubscriptionLocator.getSubscription subscriptionId, (err, subscription)-> if err? - logger.err err:err, user_id:user_id, "error getting users subscription" + logger.err err:err, subscriptionId: subscriptionId, "error getting subscription" return callback(err) if !subscription? - logger.err user_id:user_id, "no subscription found for user" + logger.err subscriptionId: subscriptionId, "no subscription found" return callback("no subscription found") limitReached = LimitationsManager.teamHasReachedMemberLimit(subscription) diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee index ac07350338..d32fd591d2 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee @@ -9,9 +9,6 @@ module.exports = SubscriptionDomainHandler = licence = SubscriptionDomainHandler._findDomainLicence(user.email) return licence - rejectInvitationToGroup: (user, subscription, callback)-> - removeUserFromGroup(subscription.admin_id, user._id, callback) - getDomainLicencePage: (user)-> licence = SubscriptionDomainHandler._findDomainLicence(user.email) if licence?.verifyEmail diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee index 8e84f1fb0b..0fb7b6e7c9 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee @@ -9,46 +9,56 @@ async = require("async") module.exports = - addUserToGroup: (req, res)-> + addUserToGroup: (req, res, next)-> adminUserId = AuthenticationController.getLoggedInUserId(req) newEmail = req.body?.email?.toLowerCase()?.trim() - logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription" - SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)-> - if err? - logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group" - return res.sendStatus 500 - result = - user:user - if err and err.limitReached - result.limitReached = true - res.json(result) - removeUserFromGroup: (req, res)-> + getManagedSubscription adminUserId, (error, subscription) -> + return next(error) if error? + + logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription" + + SubscriptionGroupHandler.addUserToGroup subscription._id, newEmail, (err, user)-> + if err? + logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group" + return res.sendStatus 500 + result = + user:user + if err and err.limitReached + result.limitReached = true + res.json(result) + + removeUserFromGroup: (req, res, next)-> adminUserId = AuthenticationController.getLoggedInUserId(req) userToRemove_id = req.params.user_id - logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription" - SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)-> - if err? - logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group" - return res.sendStatus 500 - res.send() + getManagedSubscription adminUserId, (error, subscription) -> + return next(error) if error? + logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription" + SubscriptionGroupHandler.removeUserFromGroup subscription._id, userToRemove_id, (err)-> + if err? + logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group" + return res.sendStatus 500 + res.send() - removeSelfFromGroup: (req, res)-> + removeSelfFromGroup: (req, res, next)-> adminUserId = req.query.admin_user_id userToRemove_id = AuthenticationController.getLoggedInUserId(req) - logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request" - SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)-> - if err? - logger.err err:err, userToRemove_id:userToRemove_id, adminUserId:adminUserId, "error removing self from group" - return res.sendStatus 500 - res.send() + getManagedSubscription adminUserId, (error, subscription) -> + return next(error) if error? + logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request" + SubscriptionGroupHandler.removeUserFromGroup subscription._id, userToRemove_id, (err)-> + if err? + logger.err err:err, userToRemove_id:userToRemove_id, adminUserId:adminUserId, "error removing self from group" + return res.sendStatus 500 + res.send() - renderSubscriptionGroupAdminPage: (req, res)-> + renderSubscriptionGroupAdminPage: (req, res, next)-> user_id = AuthenticationController.getLoggedInUserId(req) - SubscriptionLocator.getUsersSubscription user_id, (err, subscription)-> + getManagedSubscription user_id, (error, subscription)-> + return next(error) if error? if !subscription?.groupPlan return res.redirect("/user/subscription") - SubscriptionGroupHandler.getPopulatedListOfMembers user_id, (err, users)-> + SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)-> res.render "subscriptions/group_admin", title: 'group_admin' users: users @@ -57,10 +67,11 @@ module.exports = exportGroupCsv: (req, res)-> user_id = AuthenticationController.getLoggedInUserId(req) logger.log user_id: user_id, "exporting group csv" - SubscriptionLocator.getUsersSubscription user_id, (err, subscription)-> + getManagedSubscription user_id, (err, subscription)-> + return next(error) if error? if !subscription.groupPlan return res.redirect("/") - SubscriptionGroupHandler.getPopulatedListOfMembers user_id, (err, users)-> + SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)-> groupCsv = "" for user in users groupCsv += user.email + "\n" @@ -70,3 +81,13 @@ module.exports = ) res.contentType('text/csv') res.send(groupCsv) + + +getManagedSubscription = (managerId, callback) -> + SubscriptionLocator.findManagedSubscription managerId, (err, subscription)-> + if subscription? + logger.log managerId: managerId, "got managed subscription" + else + err ||= new Error("No subscription found managed by user #{managerId}") + + return callback(err, subscription) diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index 2f3ac4a803..badcde4c36 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -14,19 +14,19 @@ NotificationsBuilder = require("../Notifications/NotificationsBuilder") module.exports = SubscriptionGroupHandler = - addUserToGroup: (adminUserId, newEmail, callback)-> - logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group" - LimitationsManager.hasGroupMembersLimitReached adminUserId, (err, limitReached, subscription)-> + addUserToGroup: (subscriptionId, newEmail, callback)-> + logger.log subscriptionId:subscriptionId, newEmail:newEmail, "adding user to group" + LimitationsManager.hasGroupMembersLimitReached subscriptionId, (err, limitReached, subscription)-> if err? - logger.err err:err, adminUserId:adminUserId, newEmail:newEmail, "error checking if limit reached for group plan" + logger.err err:err, subscriptionId:subscriptionId, newEmail:newEmail, "error checking if limit reached for group plan" return callback(err) if limitReached - logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group" + logger.err subscriptionId:subscriptionId, newEmail:newEmail, "group subscription limit reached not adding user to group" return callback(limitReached:limitReached) UserGetter.getUserByAnyEmail newEmail, (err, user)-> return callback(err) if err? if user? - SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)-> + SubscriptionUpdater.addUserToGroup subscriptionId, user._id, (err)-> if err? logger.err err:err, "error adding user to group" return callback(err) @@ -34,30 +34,25 @@ module.exports = SubscriptionGroupHandler = userViewModel = buildUserViewModel(user) callback(err, userViewModel) else - TeamInvitesHandler.createInvite adminUserId, newEmail, (err) -> + TeamInvitesHandler.createInvite subscriptionId, newEmail, (err) -> return callback(err) if err? userViewModel = buildEmailInviteViewModel(newEmail) callback(err, userViewModel) - removeUserFromGroup: (adminUser_id, userToRemove_id, callback)-> - SubscriptionUpdater.removeUserFromGroup adminUser_id, userToRemove_id, callback + removeUserFromGroup: (subscriptionId, userToRemove_id, callback)-> + SubscriptionUpdater.removeUserFromGroup subscriptionId, userToRemove_id, callback replaceUserReferencesInGroups: (oldId, newId, callback) -> Subscription.update {admin_id: oldId}, {admin_id: newId}, (error) -> callback(error) if error? - # Mongo won't let us pull and addToSet in the same query, so do it in - # two. Note we need to add first, since the query is based on the old user. - query = { member_ids: oldId } - addNewUserUpdate = $addToSet: { member_ids: newId } - removeOldUserUpdate = $pull: { member_ids: oldId } + replaceInArray Subscription, "manager_ids", oldId, newId, (error) -> + callback(error) if error? - Subscription.update query, addNewUserUpdate, { multi: true }, (error) -> - return callback(error) if error? - Subscription.update query, removeOldUserUpdate, { multi: true }, callback + replaceInArray Subscription, "member_ids", oldId, newId, callback - getPopulatedListOfMembers: (adminUser_id, callback)-> - SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)-> + getPopulatedListOfMembers: (subscriptionId, callback)-> + SubscriptionLocator.getSubscription subscriptionId, (err, subscription)-> users = [] for email in subscription.invited_emails or [] @@ -87,6 +82,24 @@ module.exports = SubscriptionGroupHandler = logger.log user_id:user_id, subscription_id:subscription_id, partOfGroup:partOfGroup, "checking if user is part of a group" callback(err, partOfGroup) +replaceInArray = (model, property, oldValue, newValue, callback) -> + logger.log "Replacing #{oldValue} with #{newValue} in #{property} of #{model}" + + # Mongo won't let us pull and addToSet in the same query, so do it in + # two. Note we need to add first, since the query is based on the old user. + query = {} + query[property] = oldValue + + setNewValue = {} + setNewValue[property] = newValue + + setOldValue = {} + setOldValue[property] = oldValue + + 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 diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee index ef6693f9b8..3a8d29673b 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee @@ -2,7 +2,7 @@ Subscription = require('../../models/Subscription').Subscription logger = require("logger-sharelatex") ObjectId = require('mongoose').Types.ObjectId -module.exports = +module.exports = SubscriptionLocator = getUsersSubscription: (user_or_id, callback)-> if user_or_id? and user_or_id._id? @@ -14,6 +14,10 @@ module.exports = logger.log user_id:user_id, "got users subscription" callback(err, subscription) + findManagedSubscription: (managerId, callback)-> + logger.log managerId: managerId, "finding managed subscription" + Subscription.findOne manager_ids: managerId, callback + getMemberSubscriptions: (user_or_id, callback) -> if user_or_id? and user_or_id._id? user_id = user_or_id._id diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee index 7867db29b9..edcc58d57e 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee @@ -25,13 +25,13 @@ module.exports = SubscriptionUpdater = return callback(err) if err? SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback - addUserToGroup: (adminUserId, userId, callback)-> - @addUsersToGroup(adminUserId, [userId], callback) + addUserToGroup: (subscriptionId, userId, callback)-> + @addUsersToGroup(subscriptionId, [userId], callback) - addUsersToGroup: (adminUserId, memberIds, callback)-> - logger.log adminUserId: adminUserId, memberIds: memberIds, "adding members into mongo subscription" + addUsersToGroup: (subscriptionId, memberIds, callback)-> + logger.log subscriptionId: subscriptionId, memberIds: memberIds, "adding members into mongo subscription" searchOps = - admin_id: adminUserId + _id: subscriptionId insertOperation = { $push: { member_ids: { $each: memberIds } } } @@ -46,9 +46,9 @@ module.exports = SubscriptionUpdater = async.map userIds, FeaturesUpdater.refreshFeatures, callback - removeUserFromGroup: (adminUser_id, user_id, callback)-> + removeUserFromGroup: (subscriptionId, user_id, callback)-> searchOps = - admin_id: adminUser_id + _id: subscriptionId removeOperation = "$pull": {member_ids:user_id} Subscription.update searchOps, removeOperation, (err)-> @@ -71,23 +71,23 @@ module.exports = SubscriptionUpdater = _createNewSubscription: (adminUser_id, callback)-> logger.log adminUser_id:adminUser_id, "creating new subscription" - subscription = new Subscription(admin_id:adminUser_id) + subscription = new Subscription(admin_id:adminUser_id, manager_ids: [adminUser_id]) subscription.freeTrial.allowed = false subscription.save (err)-> callback err, subscription _updateSubscriptionFromRecurly: (recurlySubscription, subscription, callback)-> logger.log recurlySubscription:recurlySubscription, subscription:subscription, "updaing subscription" - plan = PlansLocator.findLocalPlanInSettings(recurlySubscription.plan.plan_code) if recurlySubscription.state == "expired" - subscription.recurlySubscription_id = undefined - subscription.planCode = Settings.defaultPlanCode - else - subscription.recurlySubscription_id = recurlySubscription.uuid - subscription.freeTrial.expiresAt = undefined - subscription.freeTrial.planCode = undefined - subscription.freeTrial.allowed = true - subscription.planCode = recurlySubscription.plan.plan_code + return SubscriptionUpdater.deleteSubscription subscription._id, callback + subscription.recurlySubscription_id = recurlySubscription.uuid + subscription.freeTrial.expiresAt = undefined + subscription.freeTrial.planCode = undefined + subscription.freeTrial.allowed = true + subscription.planCode = recurlySubscription.plan.plan_code + plan = PlansLocator.findLocalPlanInSettings(subscription.planCode) + if !plan? + return callback(new Error("plan code not found: #{subscription.planCode}")) if plan.groupPlan subscription.groupPlan = true subscription.membersLimit = plan.membersLimit diff --git a/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee index c3b3b11089..7bbfd78780 100644 --- a/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee @@ -76,7 +76,7 @@ module.exports = TeamInvitesHandler = return callback(err) if err? return callback(new Errors.NotFoundError('invite not found')) unless invite? - SubscriptionUpdater.addUserToGroup subscription.admin_id, userId, (err) -> + SubscriptionUpdater.addUserToGroup subscription._id, userId, (err) -> return callback(err) if err? removeInviteFromTeam(subscription.id, invite.email, callback) diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee index 9b102d13dd..823919f999 100644 --- a/services/web/app/coffee/Features/User/UserController.coffee +++ b/services/web/app/coffee/Features/User/UserController.coffee @@ -13,6 +13,7 @@ UserSessionsManager = require("./UserSessionsManager") UserUpdater = require("./UserUpdater") SudoModeHandler = require('../SudoMode/SudoModeHandler') settings = require "settings-sharelatex" +Errors = require "../Errors/Errors" module.exports = UserController = @@ -100,7 +101,7 @@ module.exports = UserController = UserUpdater.changeEmailAddress user_id, newEmail, (err)-> if err? logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address" - if err.message == "alread_exists" + if err instanceof Errors.EmailExistsError message = req.i18n.translate("email_already_registered") else message = req.i18n.translate("problem_changing_email_address") diff --git a/services/web/app/coffee/Features/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee index b0676e09e0..17248b38ae 100644 --- a/services/web/app/coffee/Features/User/UserCreator.coffee +++ b/services/web/app/coffee/Features/User/UserCreator.coffee @@ -1,20 +1,23 @@ User = require("../../models/User").User logger = require("logger-sharelatex") metrics = require('metrics-sharelatex') -{ addAffiliation } = require("./UserAffiliationsManager") +{ addAffiliation } = require("../Institutions/InstitutionsAPI") module.exports = UserCreator = - createNewUser: (opts, callback)-> - logger.log opts:opts, "creating new user" + createNewUser: (attributes, options, callback = (error, user) ->)-> + if arguments.length == 2 + callback = options + options = {} + logger.log user: attributes, "creating new user" user = new User() - username = opts.email.match(/^[^@]*/) - if !opts.first_name? or opts.first_name == "" - opts.first_name = username[0] + username = attributes.email.match(/^[^@]*/) + if !attributes.first_name? or attributes.first_name == "" + attributes.first_name = username[0] - for key, value of opts + for key, value of attributes user[key] = value user.ace.syntaxValidation = true @@ -27,6 +30,7 @@ module.exports = UserCreator = user.save (err)-> callback(err, user) + return if options?.skip_affiliation # call addaffiliation after the main callback so it runs in the # background. There is no guaranty this will run so we must no rely on it addAffiliation user._id, user.email, (error) -> diff --git a/services/web/app/coffee/Features/User/UserDeleter.coffee b/services/web/app/coffee/Features/User/UserDeleter.coffee index c09bebbd8c..149959c00f 100644 --- a/services/web/app/coffee/Features/User/UserDeleter.coffee +++ b/services/web/app/coffee/Features/User/UserDeleter.coffee @@ -4,7 +4,7 @@ ProjectDeleter = require("../Project/ProjectDeleter") logger = require("logger-sharelatex") SubscriptionHandler = require("../Subscription/SubscriptionHandler") async = require("async") -{ deleteAffiliations } = require("./UserAffiliationsManager") +{ deleteAffiliations } = require("../Institutions/InstitutionsAPI") module.exports = diff --git a/services/web/app/coffee/Features/User/UserEmailsController.coffee b/services/web/app/coffee/Features/User/UserEmailsController.coffee index af1a355d6e..e37a0452e7 100644 --- a/services/web/app/coffee/Features/User/UserEmailsController.coffee +++ b/services/web/app/coffee/Features/User/UserEmailsController.coffee @@ -3,7 +3,7 @@ UserGetter = require("./UserGetter") UserUpdater = require("./UserUpdater") EmailHelper = require("../Helpers/EmailHelper") UserEmailsConfirmationHandler = require "./UserEmailsConfirmationHandler" -{ endorseAffiliation } = require("./UserAffiliationsManager") +{ endorseAffiliation } = require("../Institutions/InstitutionsAPI") logger = require("logger-sharelatex") Errors = require "../Errors/Errors" @@ -26,7 +26,8 @@ module.exports = UserEmailsController = role: req.body.role department: req.body.department UserUpdater.addEmailAddress userId, email, affiliationOptions, (error)-> - return next(error) if error? + if error? + return UserEmailsController._handleEmailError error, req, res, next UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (err) -> return next(error) if error? res.sendStatus 204 @@ -47,9 +48,11 @@ module.exports = UserEmailsController = email = EmailHelper.parseEmail(req.body.email) return res.sendStatus 422 unless email? - UserUpdater.setDefaultEmailAddress userId, email, (error)-> - return next(error) if error? - res.sendStatus 200 + UserUpdater.updateV1AndSetDefaultEmailAddress userId, email, (error)-> + if error? + return UserEmailsController._handleEmailError error, req, res, next + else + return res.sendStatus 200 endorse: (req, res, next) -> @@ -61,6 +64,19 @@ module.exports = UserEmailsController = return next(error) if error? res.sendStatus 204 + resendConfirmation: (req, res, next) -> + userId = AuthenticationController.getLoggedInUserId(req) + email = EmailHelper.parseEmail(req.body.email) + return res.sendStatus 422 unless email? + UserGetter.getUserByAnyEmail email, {_id:1}, (error, user) -> + return next(error) if error? + if !user? or user?._id?.toString() != userId + logger.log {userId, email, foundUserId: user?._id}, "email doesn't match logged in user" + return res.sendStatus 422 + logger.log {userId, email}, 'resending email confirmation token' + UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (error) -> + return next(error) if error? + res.sendStatus 200 showConfirm: (req, res, next) -> res.render 'user/confirm_email', { @@ -82,3 +98,15 @@ module.exports = UserEmailsController = next(error) else res.sendStatus 200 + + _handleEmailError: (error, req, res, next) -> + if error instanceof Errors.UnconfirmedEmailError + return res.status(409).json { + message: 'email must be confirmed' + } + else if error instanceof Errors.EmailExistsError + return res.status(409).json { + message: req.i18n.translate("email_already_registered") + } + else + return next(error) \ No newline at end of file diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index bdcdd24482..f3aa4ad4d6 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -3,7 +3,8 @@ metrics = require('metrics-sharelatex') logger = require('logger-sharelatex') db = mongojs.db ObjectId = mongojs.ObjectId -{ getAffiliations } = require("./UserAffiliationsManager") +{ getUserAffiliations } = require("../Institutions/InstitutionsAPI") +Errors = require("../Errors/Errors") module.exports = UserGetter = getUser: (query, projection, callback = (error, user) ->) -> @@ -31,7 +32,7 @@ module.exports = UserGetter = return callback error if error? return callback new Error('User not Found') unless user - getAffiliations userId, (error, affiliationsData) -> + getUserAffiliations userId, (error, affiliationsData) -> return callback error if error? callback null, decorateFullEmails(user.email, user.emails, affiliationsData) @@ -77,7 +78,7 @@ module.exports = UserGetter = # check for duplicate email address. This is also enforced at the DB level ensureUniqueEmailAddress: (newEmail, callback) -> @getUserByAnyEmail newEmail, (error, user) -> - return callback(message: 'alread_exists') if user? + return callback(new Errors.EmailExistsError('alread_exists')) if user? callback(error) decorateFullEmails = (defaultEmail, emailsData, affiliationsData) -> diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee index 2a7ed62d02..5e6ea7d62b 100644 --- a/services/web/app/coffee/Features/User/UserPagesController.coffee +++ b/services/web/app/coffee/Features/User/UserPagesController.coffee @@ -68,7 +68,6 @@ module.exports = shouldAllowEditingDetails: shouldAllowEditingDetails languages: Settings.languages, accountSettingsTabActive: true - showAffiliationsUI: (req.query?.aff == "true") or false sessionsPage: (req, res, next) -> user = AuthenticationController.getSessionUser(req) diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee index df7fe93218..1291142dab 100644 --- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee @@ -1,4 +1,3 @@ -sanitize = require('sanitizer') User = require("../../models/User").User UserCreator = require("./UserCreator") UserGetter = require("./UserGetter") @@ -54,7 +53,8 @@ module.exports = UserRegistrationHandler = (cb)-> User.update {_id: user._id}, {"$set":{holdingAccount:false}}, cb (cb)-> AuthenticationManager.setUserPassword user._id, userDetails.password, cb (cb)-> - NewsLetterManager.subscribe user, -> + if userDetails.subscribeToNewsletter == "true" + NewsLetterManager.subscribe user, -> cb() #this can be slow, just fire it off ], (err)-> logger.log user: user, "registered" diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee index 948be16d41..9ee81c1ca3 100644 --- a/services/web/app/coffee/Features/User/UserUpdater.coffee +++ b/services/web/app/coffee/Features/User/UserUpdater.coffee @@ -5,9 +5,13 @@ db = mongojs.db async = require("async") ObjectId = mongojs.ObjectId UserGetter = require("./UserGetter") -{ addAffiliation, removeAffiliation } = require("./UserAffiliationsManager") +{ addAffiliation, removeAffiliation } = require("../Institutions/InstitutionsAPI") +FeaturesUpdater = require("../Subscription/FeaturesUpdater") EmailHelper = require "../Helpers/EmailHelper" Errors = require "../Errors/Errors" +Settings = require "settings-sharelatex" +request = require 'request' +NewsletterManager = require "../Newsletter/NewsletterManager" module.exports = UserUpdater = updateUser: (query, update, callback = (error) ->) -> @@ -96,17 +100,69 @@ module.exports = UserUpdater = setDefaultEmailAddress: (userId, email, callback) -> email = EmailHelper.parseEmail(email) return callback(new Error('invalid email')) if !email? - query = _id: userId, 'emails.email': email - update = $set: email: email - @updateUser query, update, (error, res) -> - if error? - logger.err error:error, 'problem setting default emails' + UserGetter.getUserEmail userId, (error, oldEmail) => + if err? return callback(error) - if res.n == 0 # TODO: Check n or nMatched? - return callback(new Error('Default email does not belong to user')) - callback() + query = _id: userId, 'emails.email': email + update = $set: email: email + @updateUser query, update, (error, res) -> + if error? + logger.err error:error, 'problem setting default emails' + return callback(error) + else if res.n == 0 # TODO: Check n or nMatched? + return callback(new Error('Default email does not belong to user')) + else + NewsletterManager.changeEmail oldEmail, email, callback - confirmEmail: (userId, email, callback) -> + + + updateV1AndSetDefaultEmailAddress: (userId, email, callback) -> + @updateEmailAddressInV1 userId, email, (error) => + return callback(error) if error? + @setDefaultEmailAddress userId, email, callback + + updateEmailAddressInV1: (userId, newEmail, callback) -> + if !Settings.apis?.v1?.url? + return callback() + UserGetter.getUser userId, { 'overleaf.id': 1, emails: 1 }, (error, user) -> + return callback(error) if error? + return callback(new Errors.NotFoundError('no user found')) if !user? + if !user.overleaf?.id? + return callback() + newEmailIsConfirmed = false + for email in user.emails + if email.email == newEmail and email.confirmedAt? + newEmailIsConfirmed = true + break + if !newEmailIsConfirmed + return callback(new Errors.UnconfirmedEmailError("can't update v1 with unconfirmed email")) + request { + baseUrl: Settings.apis.v1.url + url: "/api/v1/sharelatex/users/#{user.overleaf.id}/email" + method: 'PUT' + auth: + user: Settings.apis.v1.user + pass: Settings.apis.v1.pass + sendImmediately: true + json: + user: + email: newEmail + timeout: 5 * 1000 + }, (error, response, body) -> + if error? + error = new Errors.V1ConnectionError('No V1 connection') if error.code == 'ECONNREFUSED' + return callback(error) + if response.statusCode == 409 # Conflict + return callback(new Errors.EmailExistsError('email exists in v1')) + else if 200 <= response.statusCode < 300 + return callback() + else + return callback new Error("non-success code from v1: #{response.statusCode}") + + confirmEmail: (userId, email, confirmedAt, callback) -> + if arguments.length == 3 + callback = confirmedAt + confirmedAt = new Date() email = EmailHelper.parseEmail(email) return callback(new Error('invalid email')) if !email? logger.log {userId, email}, 'confirming user email' @@ -120,13 +176,13 @@ module.exports = UserUpdater = 'emails.email': email update = $set: - 'emails.$.confirmedAt': new Date() + 'emails.$.confirmedAt': confirmedAt @updateUser query, update, (error, res) -> return callback(error) if error? logger.log {res, userId, email}, "tried to confirm email" if res.n == 0 return callback(new Errors.NotFoundError('user id and email do no match')) - callback() + FeaturesUpdater.refreshFeatures userId, true, callback [ 'updateUser' diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index 6943a79cb2..d4444b3542 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -109,7 +109,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> logger.log user_id:user_id, ip:req?.ip, "cdnBlocked for user, not using it and turning it off for future requets" req.session.cdnBlocked = true - isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark" + isDark = req.headers?.host?.slice(0,7)?.toLowerCase().indexOf("dark") != -1 isSmoke = req.headers?.host?.slice(0,5)?.toLowerCase() == "smoke" isLive = !isDark and !isSmoke @@ -167,6 +167,11 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> path = Path.join("/img/", imgFile) return Url.resolve(staticFilesBase, path) + res.locals.mathJaxPath = res.locals.buildJsPath( + 'libs/mathjax/MathJax.js', + {cdn:false, qs:{config:'TeX-AMS_HTML'}} + ) + next() @@ -328,3 +333,8 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> defaultLineHeight : if isOl then 'normal' else 'compact' renderAnnouncements : !isOl next() + + webRouter.use (req, res, next) -> + res.locals.ExposedSettings = + isOverleaf: Settings.overleaf? + next() diff --git a/services/web/app/coffee/infrastructure/Features.coffee b/services/web/app/coffee/infrastructure/Features.coffee index afb2eefb9f..43dadcda2d 100644 --- a/services/web/app/coffee/infrastructure/Features.coffee +++ b/services/web/app/coffee/infrastructure/Features.coffee @@ -20,5 +20,12 @@ module.exports = Features = return Settings.overleaf? when 'templates' return !Settings.overleaf? + when 'affiliations' + return Settings?.apis?.v1?.url? + when 'rich-text' + isEnabled = true # Switch to false to disable + Settings.overleaf? and isEnabled + when 'redirect-sl' + return Settings.redirectToV2? else throw new Error("unknown feature: #{feature}") diff --git a/services/web/app/coffee/infrastructure/Server.coffee b/services/web/app/coffee/infrastructure/Server.coffee index cf2757c80e..bf9d6652f4 100644 --- a/services/web/app/coffee/infrastructure/Server.coffee +++ b/services/web/app/coffee/infrastructure/Server.coffee @@ -68,7 +68,7 @@ Modules.loadViewIncludes app app.use bodyParser.urlencoded({ extended: true, limit: "2mb"}) # Make sure we can process the max doc length plus some overhead for JSON encoding -app.use bodyParser.json({limit: Settings.max_doc_length + 16 * 1024}) # 16kb overhead +app.use bodyParser.json({limit: Settings.max_doc_length + 64 * 1024}) # 64kb overhead app.use multer(dest: Settings.path.uploadFolder) app.use methodOverride() diff --git a/services/web/app/coffee/models/Subscription.coffee b/services/web/app/coffee/models/Subscription.coffee index f739c14e2c..ea1c98e954 100644 --- a/services/web/app/coffee/models/Subscription.coffee +++ b/services/web/app/coffee/models/Subscription.coffee @@ -7,10 +7,12 @@ ObjectId = Schema.ObjectId SubscriptionSchema = new Schema admin_id : {type:ObjectId, ref:'User', index: {unique: true, dropDups: true}} + manager_ids : [ type:ObjectId, ref:'User' ] member_ids : [ type:ObjectId, ref:'User' ] invited_emails: [ String ] teamInvites : [ TeamInviteSchema ] recurlySubscription_id : String + teamName : {type: String} planCode : {type: String} groupPlan : {type: Boolean, default: false} membersLimit: {type:Number, default:0} diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 2d9cce105d..b590e58f5d 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -22,7 +22,7 @@ UserPagesController = require('./Features/User/UserPagesController') DocumentController = require('./Features/Documents/DocumentController') CompileManager = require("./Features/Compile/CompileManager") CompileController = require("./Features/Compile/CompileController") -ClsiCookieManager = require("./Features/Compile/ClsiCookieManager") +ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")(Settings.apis.clsi?.backendGroupName) HealthCheckController = require("./Features/HealthCheck/HealthCheckController") ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController" FileStoreController = require("./Features/FileStore/FileStoreController") @@ -115,8 +115,11 @@ module.exports = class Router UserEmailsController.showConfirm webRouter.post '/user/emails/confirm', UserEmailsController.confirm + webRouter.post '/user/emails/resend_confirmation', + AuthenticationController.requireLogin(), + UserEmailsController.resendConfirmation - unless Features.externalAuthenticationSystemUsed() + if Features.hasFeature 'affiliations' webRouter.post '/user/emails', AuthenticationController.requireLogin(), UserEmailsController.add @@ -236,8 +239,13 @@ module.exports = class Router webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2 privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory + webRouter.get "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.getLabels + webRouter.post "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.createLabel + webRouter.delete "/project/:Project_id/labels/:label_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.deleteLabel + webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject webRouter.get '/project/:project_id/export/:export_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportStatus + webRouter.get '/project/:project_id/export/:export_id/zip', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportZip webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject webRouter.get '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects diff --git a/services/web/app/views/_mixins_links.pug b/services/web/app/views/_mixins_links.pug new file mode 100644 index 0000000000..bad56ffc25 --- /dev/null +++ b/services/web/app/views/_mixins_links.pug @@ -0,0 +1,122 @@ +mixin linkAdvisors(linkText, linkClass, track) + //- To Do: verify path + - var gaCategory = track && track.category ? track.category : 'All' + - var gaAction = track && track.action ? track.action : null + - var gaLabel = track && track.label ? track.label : null + - var mb = track && track.mb ? 'true' : null + - var mbSegmentation = track && track.segmentation ? track.segmentation : null + - var trigger = track && track.trigger ? track.trigger : null + a(href="/advisors" + class=linkClass ? linkClass : '' + event-tracking-ga=gaCategory + event-tracking=gaAction + event-tracking-label=gaLabel + event-tracking-trigger=trigger + event-tracking-mb=mb + event-segmentation=mbSegmentation + ) + | #{linkText ? linkText : 'advisor programme'} + +mixin linkBenefits(linkText, linkClass) + //- To Do: verify path + a(href="/benefits" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'benefits'} + +mixin linkBlog(linkText, linkClass, slug) + if slug + a(href="/blog/#{slug}" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'blog'} + +mixin linkContact(linkText, linkClass) + a(href="/contact" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'contact'} + +mixin linkEducation(linkText, linkClass) + //- To Do: verify path + a(href="/plans" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'teaching toolkit'} + +mixin linkEmail(linkText, linkClass, email) + //- To Do: env var? + - var emailDomain = 'overleaf.com' + a(href="mailto:#{email ? email : 'contact'}@#{emailDomain}" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'email'} + +mixin linkInvite(linkText, linkClass, track) + - var gaCategory = track && track.category ? track.category : 'All' + - var gaAction = track && track.action ? track.action : null + - var gaLabel = track && track.label ? track.label : null + - var mb = track && track.mb ? 'true' : null + - var mbSegmentation = track && track.segmentation ? track.segmentation : null + - var trigger = track && track.trigger ? track.trigger : null + + a(href="/user/bonus" + class=linkClass ? linkClass : '' + event-tracking-ga=gaCategory + event-tracking=gaAction + event-tracking-label=gaLabel + event-tracking-trigger=trigger + event-tracking-mb=mb + event-segmentation=mbSegmentation + ) + | #{linkText ? linkText : 'invite your friends'} + +mixin linkPlansAndPricing(linkText, linkClass) + //- To Do: verify path + a(href="/plans" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'plans and pricing'} + +mixin linkPrintNewTab(linkText, linkClass, icon, track) + - var gaCategory = track && track.category ? track.category : null + - var gaAction = track && track.action ? track.action : null + - var gaLabel = track && track.label ? track.label : null + - var mb = track && track.mb ? 'true' : null + - var mbSegmentation = track && track.segmentation ? track.segmentation : null + - var trigger = track && track.trigger ? track.trigger : null + + a(href='?media=print' + class=linkClass ? linkClass : '' + event-tracking-ga=gaCategory + event-tracking=gaAction + event-tracking-label=gaLabel + event-tracking-trigger=trigger + event-tracking-mb=mb + event-segmentation=mbSegmentation + target="_BLANK" + ) + if icon + i(class="fa fa-print") + |   + | #{linkText ? linkText : 'print'} + +mixin linkSignIn(linkText, linkClass) + a(href="/login" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'sign in'} + +mixin linkSignUp(linkText, linkClass) + a(href="/register" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'sign up'} + +mixin linkTweet(linkText, linkClass, tweetText, track) + //- twitter-share-button is required by twitter + - var gaCategory = track && track.category ? track.category : 'All' + - var gaAction = track && track.action ? track.action : null + - var gaLabel = track && track.label ? track.label : null + - var mb = track && track.mb ? 'true' : null + - var mbSegmentation = track && track.segmentation ? track.segmentation : null + - var trigger = track && track.trigger ? track.trigger : null + a(class="twitter-share-button " + linkClass + event-tracking-ga=gaCategory + event-tracking=gaAction + event-tracking-label=gaLabel + event-tracking-trigger=trigger + event-tracking-mb=mb + event-segmentation=mbSegmentation + href="https://twitter.com/intent/tweet?text=" + tweetText + target="_BLANK" + ) #{linkText ? linkText : 'tweet'} + +mixin linkUniversities(linkText, linkClass) + //- To Do: verify path + a(href="/universities" class=linkClass ? linkClass : '') + | #{linkText ? linkText : 'universities'} diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index 5999de931b..683e2c8f0b 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -70,6 +70,7 @@ html(itemscope, itemtype='http://schema.org/Product') window.systemMessages = !{JSON.stringify(systemMessages).replace(/\//g, '\\/')}; window.ab = {}; window.user_id = '#{getLoggedInUserId()}'; + window.ExposedSettings = JSON.parse('!{JSON.stringify(ExposedSettings).replace(/\//g, "\\/")}'); - if (typeof(settings.algolia) != "undefined") script. diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 602b9af86b..08124c334c 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -65,7 +65,7 @@ block content ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }", layout="main", ng-hide="state.loading", - resize-on="layout:chat:resize", + resize-on="layout:chat:resize,history:toggle", minimum-restore-size-west="130" custom-toggler-pane=hasFeature('custom-togglers') ? "'west'" : "false" custom-toggler-msg-when-open=hasFeature('custom-togglers') ? "'" + translate("tooltip_hide_filetree") + "'" : "false" @@ -103,6 +103,8 @@ block content h3 {{ title }} .modal-body(ng-bind-html="message") + script(src=mathJaxPath) + block requirejs script(type="text/javascript" src='/socket.io/socket.io.js') @@ -131,9 +133,9 @@ block requirejs window.maxDocLength = #{maxDocLength}; window.trackChangesState = data.trackChangesState; window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)}; + window.richTextEnabled = #{richTextEnabled} window.requirejs = { "paths" : { - "mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, qs:{config:'TeX-AMS_HTML'}})}", "moment": "libs/#{lib('moment')}", "pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf", "pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}", diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index c24d967da7..69a874cc05 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -15,6 +15,7 @@ div.full-size( .ui-layout-center( ng-controller="ReviewPanelController", ng-class="{\ + 'rp-unsupported': editor.showRichText,\ 'rp-state-current-file': (reviewPanel.subView === SubViews.CUR_FILE),\ 'rp-state-current-file-expanded': (reviewPanel.subView === SubViews.CUR_FILE && ui.reviewPanelOpen),\ 'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\ @@ -25,7 +26,10 @@ div.full-size( 'rp-loading-threads': reviewPanel.loadingThreads,\ }" ) - .loading-panel(ng-show="!editor.sharejs_doc || editor.opening") + .loading-panel( + ng-show="!editor.sharejs_doc || editor.opening", + style=showRichText ? "top: 32px" : "", + ) span(ng-show="editor.open_doc_id") i.fa.fa-spin.fa-refresh |   #{translate("loading")}... @@ -39,7 +43,7 @@ div.full-size( ace-editor="editor", ng-if="!editor.showRichText", ng-show="!!editor.sharejs_doc && !editor.opening", - style=showRichText ? "top: 32px" : "", + style=richTextEnabled ? "top: 32px" : "", theme="settings.theme", keybindings="settings.mode", font-size="settings.fontSize", diff --git a/services/web/app/views/project/editor/history.pug b/services/web/app/views/project/editor/history.pug index a7a52d2927..35de68957f 100644 --- a/services/web/app/views/project/editor/history.pug +++ b/services/web/app/views/project/editor/history.pug @@ -67,3 +67,53 @@ script(type="text/ng-template", id="historyRestoreDiffModalTemplate") ) span(ng-show="!state.inflight") #{translate("restore")} span(ng-show="state.inflight") #{translate("restoring")} ... + + +script(type="text/ng-template", id="historyLabelTpl") + .history-label( + ng-class="{ 'history-label-own' : $ctrl.isOwnedByCurrentUser }" + ) + span.history-label-comment( + tooltip-append-to-body="true" + tooltip-template="'historyLabelTooltipTpl'" + tooltip-placement="left" + tooltip-enable="$ctrl.showTooltip" + ) + i.fa.fa-tag + |  {{ $ctrl.labelText }} + button.history-label-delete-btn( + ng-if="$ctrl.isOwnedByCurrentUser" + stop-propagation="click" + ng-click="$ctrl.onLabelDelete()" + ) × + +script(type="text/ng-template", id="historyLabelTooltipTpl") + .history-label-tooltip + p.history-label-tooltip-title + i.fa.fa-tag + |  {{ $ctrl.labelText }} + p.history-label-tooltip-owner #{translate("history_label_created_by")} {{ $ctrl.labelOwnerName }} + time.history-label-tooltip-datetime {{ $ctrl.labelCreationDateTime | formatDate }} + + +script(type="text/ng-template", id="historyV2DeleteLabelModalTemplate") + .modal-header + h3 #{translate("history_delete_label")} + .modal-body + .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} + .alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")} + p.help-block(ng-if="labelDetails") + | #{translate("history_are_you_sure_delete_label")} + strong "{{ labelDetails.comment }}" + | ? + .modal-footer + button.btn.btn-default( + type="button" + ng-disabled="state.inflight" + ng-click="$dismiss()" + ) #{translate("cancel")} + button.btn.btn-primary( + type="button" + ng-click="deleteLabel()" + ng-disabled="state.inflight" + ) {{ state.inflight ? '#{translate("history_deleting_label")}' : '#{translate("history_delete_label")}' }} diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug index fa7a90b20e..5e4c4b66c6 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -3,13 +3,26 @@ aside.change-list( ng-controller="HistoryV2ListController" ) history-entries-list( + ng-if="!history.showOnlyLabels && !history.error" entries="history.updates" current-user="user" + users="projectUsers" load-entries="loadMore()" load-disabled="history.loading || history.atEnd" load-initialize="ui.view == 'history'" is-loading="history.loading" on-entry-select="handleEntrySelect(selectedEntry)" + on-label-delete="handleLabelDelete(label)" + ) + history-labels-list( + ng-if="history.showOnlyLabels && !history.error" + labels="history.labels" + current-user="user" + users="projectUsers" + is-loading="history.loading" + selected-label="history.selection.label" + on-label-select="handleLabelSelect(label)" + on-label-delete="handleLabelDelete(label)" ) aside.change-list( @@ -65,6 +78,14 @@ aside.change-list( ) div.description(ng-click="select()") + history-label( + ng-repeat="label in update.labels" + label-text="label.comment" + label-owner-name="getDisplayNameById(label.user_id)" + label-creation-date-time="label.created_at" + is-owned-by-current-user="label.user_id === user.id" + on-label-delete="deleteLabel(label)" + ) div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") | #{translate("file_action_edited")} @@ -106,8 +127,9 @@ script(type="text/ng-template", id="historyEntriesListTpl") ng-repeat="entry in $ctrl.entries" entry="entry" current-user="$ctrl.currentUser" + users="$ctrl.users" on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })" - ng-show="!$ctrl.isLoading" + on-label-delete="$ctrl.onLabelDelete({ label: label })" ) .loading(ng-show="$ctrl.isLoading") i.fa.fa-spin.fa-refresh @@ -129,6 +151,15 @@ script(type="text/ng-template", id="historyEntryTpl") time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }} .history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })") + history-label( + ng-repeat="label in $ctrl.entry.labels | orderBy : '-created_at'" + label-text="label.comment" + label-owner-name="$ctrl.displayNameById(label.user_id)" + label-creation-date-time="label.created_at" + is-owned-by-current-user="label.user_id === $ctrl.currentUser.id" + on-label-delete="$ctrl.onLabelDelete({ label: label })" + ) + ol.history-entry-changes li.history-entry-change( ng-repeat="pathname in ::$ctrl.entry.pathnames" @@ -148,6 +179,7 @@ script(type="text/ng-template", id="historyEntryTpl") ng-if="::project_op.remove" ) #{translate("file_action_deleted")} span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }} + .history-entry-metadata time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} span @@ -171,4 +203,37 @@ script(type="text/ng-template", id="historyEntryTpl") li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0") span.name( ng-style="$ctrl.getUserCSSStyle();" - ) #{translate("anonymous")} \ No newline at end of file + ) #{translate("anonymous")} + +script(type="text/ng-template", id="historyLabelsListTpl") + .history-labels-list + .history-entry-label( + ng-repeat="label in $ctrl.labels track by label.id" + ng-click="$ctrl.onLabelSelect({ label: label })" + ng-class="{ 'history-entry-label-selected': label.id === $ctrl.selectedLabel.id }" + ) + history-label( + show-tooltip="false" + label-text="label.comment" + is-owned-by-current-user="label.user_id === $ctrl.currentUser.id" + on-label-delete="$ctrl.onLabelDelete({ label: label })" + ) + .history-entry-label-metadata + .history-entry-label-metadata-user(ng-init="user = $ctrl.getUserById(label.user_id)") + | Saved by + span.name( + ng-if="user && user._id !== $ctrl.currentUser.id" + ng-style="$ctrl.getUserCSSStyle(user, label);" + ) {{ ::$ctrl.displayName(user) }} + span.name( + ng-if="user && user._id == $ctrl.currentUser.id" + ng-style="$ctrl.getUserCSSStyle(user, label);" + ) You + span.name( + ng-if="user == null" + ng-style="$ctrl.getUserCSSStyle(user, label);" + ) #{translate("anonymous")} + time.history-entry-label-metadata-time {{ ::label.created_at | formatDate }} + .loading(ng-show="$ctrl.isLoading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... \ No newline at end of file diff --git a/services/web/app/views/project/editor/history/previewPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug index 3d7a1ac3df..e75af8db90 100644 --- a/services/web/app/views/project/editor/history/previewPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -45,7 +45,7 @@ text="history.diff.text", highlights="history.diff.highlights", read-only="true", - resize-on="layout:main:resize", + resize-on="layout:main:resize,history:toggle", navigate-highlights="true" ) .alert.alert-info(ng-if="history.diff.binary") @@ -70,12 +70,24 @@ font-size="settings.fontSize", text="history.selectedFile.text", read-only="true", - resize-on="layout:main:resize", + resize-on="layout:main:resize,history:toggle", ) .alert.alert-info(ng-if="history.selectedFile.binary") | We're still working on showing image and binary changes, sorry. Stay tuned! .loading-panel(ng-show="history.selectedFile.loading") i.fa.fa-spin.fa-refresh |   #{translate("loading")}... + .error-panel(ng-show="history.error") + .alert.alert-danger + p + | #{translate("generic_history_error")} + a( + ng-href="mailto:#{settings.adminEmail}?Subject=Error%20loading%20history%20for%project%20{{ project_id }}" + ) #{settings.adminEmail} + p.clearfix + a.alert-link-as-btn.pull-right( + href + ng-click="toggleHistory()" + ) #{translate("back_to_editor")} .error-panel(ng-show="history.selectedFile.error") .alert.alert-danger #{translate("generic_something_went_wrong")} diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug index 799a7136f3..a683facba4 100644 --- a/services/web/app/views/project/editor/history/toolbarV2.pug +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -1,13 +1,75 @@ .history-toolbar( + ng-controller="HistoryV2ToolbarController" ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" ) - span(ng-show="history.loadingFileTree") + span.history-toolbar-selected-version(ng-show="history.loadingFileTree") i.fa.fa-spin.fa-refresh |    #{translate("loading")}... - span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")}  + span.history-toolbar-selected-version( + ng-show="!history.loadingFileTree && !history.showOnlyLabels && !history.error" + ) #{translate("browsing_project_as_of")}  time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} - .history-toolbar-btn( - ng-click="toggleHistoryViewMode();" - ) - i.fa - | #{translate("compare_to_another_version")} \ No newline at end of file + span.history-toolbar-selected-version( + ng-show="!history.loadingFileTree && history.showOnlyLabels && history.selection.label && !history.error" + ) #{translate("browsing_project_labelled")}  + span.history-toolbar-selected-label "{{ history.selection.label.comment }}" + div.history-toolbar-actions( + ng-if="!history.error" + ) + button.history-toolbar-btn( + ng-click="showAddLabelDialog();" + ng-if="!history.showOnlyLabels" + ng-disabled="history.loadingFileTree" + ) + i.fa.fa-tag + |  #{translate("history_label_this_version")} + button.history-toolbar-btn( + ng-click="toggleHistoryViewMode();" + ng-disabled="history.loadingFileTree" + ) + i.fa.fa-exchange + |  #{translate("compare_to_another_version")} + + .history-toolbar-entries-list( + ng-if="!history.error" + ) + toggle-switch( + ng-model="history.showOnlyLabels" + label-true=translate("history_view_labels") + label-false=translate("history_view_all") + description=translate("history_view_a11y_description") + ) + +script(type="text/ng-template", id="historyV2AddLabelModalTemplate") + form( + name="addLabelModalForm" + ng-submit="addLabelModalFormSubmit();" + novalidate + ) + .modal-header + h3 #{translate("history_add_label")} + .modal-body + .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} + .alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")} + .form-group + input.form-control( + type="text" + placeholder=translate("history_new_label_name") + ng-model="inputs.labelName" + focus-on="open" + required + ) + p.help-block(ng-if="update") + | #{translate("history_new_label_added_at")} + strong {{ update.meta.end_ts | formatDate:'ddd Do MMM YYYY, h:mm a' }} + .modal-footer + button.btn.btn-default( + type="button" + ng-disabled="state.inflight" + ng-click="$dismiss()" + ) #{translate("cancel")} + input.btn.btn-primary( + ng-disabled="addLabelModalForm.$invalid || state.inflight" + ng-value="state.inflight ? '" + translate("history_adding_label") + "' : '" + translate("history_add_label") + "'" + type="submit" + ) \ No newline at end of file diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug index 2cedc0b37e..bbfa4c480c 100644 --- a/services/web/app/views/project/editor/left-menu.pug +++ b/services/web/app/views/project/editor/left-menu.pug @@ -188,6 +188,15 @@ aside#left-menu.full-size( option(value="pdfjs") #{translate("built_in")} option(value="native") #{translate("native")} + if (getSessionUser() && getSessionUser().isAdmin && typeof(allowedImageNames) !== 'undefined' && allowedImageNames.length > 0) + .form-controls(ng-show="permissions.write") + label(for="imageName") #{translate("TeXLive")} + select( + name="imageName" + ng-model="project.imageName" + ) + each image in allowedImageNames + option(value=image.imageName) #{image.imageDesc} h4 #{translate("hotkeys")} ul.list-unstyled.nav diff --git a/services/web/app/views/project/editor/review-panel.pug b/services/web/app/views/project/editor/review-panel.pug index 168947c16a..8328d313e5 100644 --- a/services/web/app/views/project/editor/review-panel.pug +++ b/services/web/app/views/project/editor/review-panel.pug @@ -1,4 +1,4 @@ -#review-panel +#review-panel(style=richTextEnabled ? "top: 32px" : "") .rp-in-editor-widgets a.rp-track-changes-indicator( href @@ -235,6 +235,13 @@ i.fa.fa-list span.rp-nav-label #{translate("overview")} + .rp-unsupported-msg-wrapper + .rp-unsupported-msg + i.fa.fa-5x.fa-exclamation-triangle + p.rp-unsupported-msg-title Sorry, Track Changes is not supported in Rich Text mode (yet). + p We didn't want to delay your ability to use Rich Text mode so we've launched without support for Track Changes. + p We're working hard to include Track Changes as soon as possible. + script(type='text/ng-template', id='changeEntryTemplate') div diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 2bbde60e7b..d3d8e25f23 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -192,11 +192,14 @@ script(type='text/ng-template', id='deleteProjectsModalTemplate') ng-click="cancel()" ) × h3(ng-if="action == 'delete'") #{translate("delete_projects")} + h3(ng-if="action == 'archive'") #{translate("archive_projects")} h3(ng-if="action == 'leave'") #{translate("leave_projects")} h3(ng-if="action == 'delete-and-leave'") #{translate("delete_and_leave_projects")} + h3(ng-if="action == 'archive-and-leave'") #{translate("archive_and_leave_projects")} .modal-body div(ng-show="projectsToDelete.length > 0") - p #{translate("about_to_delete_projects")} + p(ng-if="action == 'delete' || action == 'delete-and-leave'") #{translate("about_to_delete_projects")} + p(ng-if="action == 'archive' || action == 'archive-and-leave'") #{translate("about_to_archive_projects")} ul li(ng-repeat="project in projectsToDelete | orderBy:'name'") strong {{project.name}} @@ -345,13 +348,11 @@ script(type="text/ng-template", id="v1ImportModalTemplate") i.fa.fa-flask .v1-import-col h2.v1-import-title #[strong Warning:] Overleaf v2 is Experimental - p We are still working hard to bring some Overleaf v1 features to the v2 editor. In v2 there is: + p We are still working hard to bring some Overleaf v1 features to the v2 editor. In v2: ul - li No Journals and Services menu to submit directly to our partners yet - li No Rich Text (WYSIWYG) mode yet - li No linked files (to URLs or to files in other Overleaf projects) yet - li No Zotero and CiteULike integrations yet - li No labelled versions yet + li You may not be able to access all of your Labelled versions yet + li There are no Zotero and CiteULike integrations yet + li Some Journals and Services in the Submit menu don't support direct submissions yet p.row-spaced-small | If you currently use the Overleaf Git bridge with your v1 project, you can migrate your project to the Overleaf v2 GitHub integration. | diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 0a37b90dd8..51efcd5125 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -14,5 +14,5 @@ block content .content.plans(ng-controller="PlansController") .container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'") include _plans_page_details_more - .container(ng-cloak ng-if="plansVariant === 'default' || !shouldABTestPlans") + .container(ng-cloak ng-if="plansVariant === 'default' || !shouldABTestPlans || timeout") include _plans_page_details_less diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index a781cd708a..d231047d8d 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -9,7 +9,7 @@ block content .page-header h1 #{translate("account_settings")} .account-settings(ng-controller="AccountSettingsController", ng-cloak) - if locals.showAffiliationsUI + if hasFeature('affiliations') include settings/user-affiliations form-messages(for="settingsForm") @@ -22,25 +22,26 @@ block content h3 #{translate("update_account_info")} form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate) input(type="hidden", name="_csrf", value=csrfToken) - if !externalAuthenticationSystemUsed() - .form-group - label(for='email') #{translate("email")} - input.form-control( - type='email', - name='email', - placeholder="email@example.com" - required, - ng-model="email", - ng-init="email = "+JSON.stringify(user.email), - ng-model-options="{ updateOn: 'blur' }" - ) - span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") - | #{translate("must_be_email_address")} - else - // show the email, non-editable - .form-group - label.control-label #{translate("email")} - div.form-control(readonly="true") #{user.email} + if !hasFeature('affiliations') + if !externalAuthenticationSystemUsed() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + required, + ng-model="email", + ng-init="email = "+JSON.stringify(user.email), + ng-model-options="{ updateOn: 'blur' }" + ) + span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") + | #{translate("must_be_email_address")} + else + // show the email, non-editable + .form-group + label.control-label #{translate("email")} + div.form-control(readonly="true") #{user.email} if shouldAllowEditingDetails .form-group @@ -166,7 +167,12 @@ block content ) i.fa.fa-check | #{translate("unsubscribed")} - + + if !settings.overleaf && user.overleaf + p + | Please note: If you have linked your account with Overleaf + | v2, then deleting your ShareLaTeX account will also delete + | account and all of it's associated projects and data. p #{translate("need_to_leave")} a(href, ng-click="deleteAccount()") #{translate("delete_your_account")} diff --git a/services/web/app/views/user/settings/user-affiliations.pug b/services/web/app/views/user/settings/user-affiliations.pug index 6b08b09346..32b7d8fdf3 100644 --- a/services/web/app/views/user/settings/user-affiliations.pug +++ b/services/web/app/views/user/settings/user-affiliations.pug @@ -3,55 +3,101 @@ form.row( name="affiliationsForm" ) .col-md-12 - h3 Emails and Affiliations - p.small Add additional email addresses to your account to access any upgrades your university or institution has, to make it easier for collaborators to find you, and to make sure you can recover your account. + h3 #{translate("emails_and_affiliations_title")} + p.small #{translate("emails_and_affiliations_explanation")} table.table.affiliations-table thead tr - th.affiliations-table-email Email - th.affiliations-table-institution Institution and role + th.affiliations-table-email #{translate("email")} + th.affiliations-table-institution #{translate("institution_and_role")} th.affiliations-table-inline-actions tbody tr( ng-repeat="userEmail in userEmails" ) - td {{ userEmail.email + (userEmail.default ? ' (default)' : '') }} + td + | {{ userEmail.email + (userEmail.default ? ' (default)' : '') }} + div(ng-if="!userEmail.confirmedAt").small + strong #{translate('unconfirmed')}. + | + | #{translate('please_check_your_inbox')}. + br + a( + href, + ng-click="resendConfirmationEmail(userEmail)" + ) #{translate('resend_confirmation_email')} td - div(ng-if="userEmail.affiliation.institution") {{ userEmail.affiliation.institution.name }} - div(ng-if="userEmail.affiliation.role || userEmail.affiliation.department") - span(ng-if="userEmail.affiliation.role") {{ userEmail.affiliation.role }} - span(ng-if="userEmail.affiliation.role && userEmail.affiliation.department") , - span(ng-if="userEmail.affiliation.department") {{ userEmail.affiliation.department }} - td - a( - href + div(ng-if="userEmail.affiliation.institution") + div {{ userEmail.affiliation.institution.name }} + span.small + a( + href + ng-if="!isChangingAffiliation(userEmail.email) && !userEmail.affiliation.role && !userEmail.affiliation.department" + ng-click="changeAffiliation(userEmail);" + ) #{translate("add_role_and_department")} + div.small( + ng-if="!isChangingAffiliation(userEmail.email) && (userEmail.affiliation.role || userEmail.affiliation.department)" + ) + span(ng-if="userEmail.affiliation.role") {{ userEmail.affiliation.role }} + span(ng-if="userEmail.affiliation.role && userEmail.affiliation.department") , + span(ng-if="userEmail.affiliation.department") {{ userEmail.affiliation.department }} + br + a( + href + ng-click="changeAffiliation(userEmail);" + ) #{translate("change")} + .affiliation-change-container( + ng-if="isChangingAffiliation(userEmail.email)" + ) + affiliation-form( + affiliation-data="affiliationToChange" + show-university-and-country="false" + show-role-and-department="true" + ) + .affiliation-change-actions.small + a( + href + ng-click="saveAffiliationChange(userEmail);" + ) #{translate("save_or_cancel-save")} + |  #{translate("save_or_cancel-or" )}  + a( + href + ng-click="cancelAffiliationChange();" + ) #{translate("save_or_cancel-cancel")} + td.affiliations-table-inline-actions + // Disabled buttons don't work with tooltips, due to pointer-events: none, + // so create a wrapper for the tooltip + div.affiliations-table-inline-action-disabled-wrapper( + tooltip=translate("please_confirm_your_email_before_making_it_default") + tooltip-enable="!ui.isMakingRequest" + ng-if="!userEmail.default && (!userEmail.confirmedAt || ui.isMakingRequest)" + ) + button.btn.btn-sm.btn-success.affiliations-table-inline-action( + disabled + ) #{translate("make_default")} + button.btn.btn-sm.btn-success.affiliations-table-inline-action( + ng-if="!userEmail.default && (userEmail.confirmedAt && !ui.isMakingRequest)" + ng-click="setDefaultUserEmail(userEmail)" + ) #{translate("make_default")} + |   + button.btn.btn-sm.btn-danger.affiliations-table-inline-action( ng-if="!userEmail.default" - ng-click="setDefaultUserEmail(userEmail.email)" - ) Make default - br - a( - href - ng-if="!userEmail.default" - ng-click="removeUserEmail(userEmail.email)" - ) Remove + ng-click="removeUserEmail(userEmail)" + ng-disabled="ui.isMakingRequest" + tooltip=translate("remove") + ) + i.fa.fa-fw.fa-trash tr.affiliations-table-highlighted-row( - ng-if="ui.isLoadingEmails" - ) - td.text-center(colspan="3") - i.fa.fa-fw.fa-spin.fa-refresh - |  Loading... - - tr.affiliations-table-highlighted-row( - ng-if="!ui.showAddEmailUI && !ui.isLoadingEmails" + ng-if="!ui.showAddEmailUI && !ui.isMakingRequest" ) td(colspan="3") a( href ng-click="showAddEmailForm()" - ) Add another email + ) #{translate("add_another_email")} tr.affiliations-table-highlighted-row( - ng-if="ui.showAddEmailUI" + ng-if="ui.showAddEmailUI && !ui.isLoadingEmails" ) td .affiliations-form-group @@ -67,106 +113,129 @@ form.row( input-required="true" ) td - .affiliations-table-label( + p.affiliations-table-label( ng-if="newAffiliation.university && !ui.showManualUniversitySelectionUI" ) - | {{ newAffiliation.university.name }} ( - a( - href - ng-click="selectUniversityManually();" - ) change - | ) + | {{ newAffiliation.university.name }} + span.small + | ( + a( + href + ng-click="selectUniversityManually();" + ) #{translate("change")} + | ) .affiliations-table-label( ng-if="!newAffiliation.university && !ui.isValidEmail && !ui.showManualUniversitySelectionUI" - ) Start by adding your email address. + ) #{translate("start_by_adding_your_email")} .affiliations-table-label( ng-if="!newAffiliation.university && ui.isValidEmail && !ui.isBlacklistedEmail && !ui.showManualUniversitySelectionUI" ) - | Is your email affiliated with an institution? + | #{translate("is_email_affiliated")} br a( href ng-click="selectUniversityManually();" - ) Let us know - .affiliations-form-group( - ng-if="ui.showManualUniversitySelectionUI" + ) #{translate("let_us_know")} + affiliation-form( + affiliation-data="newAffiliation" + show-university-and-country="ui.showManualUniversitySelectionUI" + show-role-and-department="ui.isValidEmail && newAffiliation.university" ) - ui-select( - ng-model="newAffiliation.country" - ) - ui-select-match( - placeholder="Country" - ) {{ $select.selected.name }} - ui-select-choices( - repeat="country in countries | filter: $select.search" - ) - span( - ng-bind="country.name" - s) - .affiliations-form-group( - ng-if="ui.showManualUniversitySelectionUI" - ) - ui-select( - ng-model="newAffiliation.university" - ng-disabled="!newAffiliation.country" - tagging="addUniversityToSelection" - tagging-label="false" - ) - ui-select-match( - placeholder="Institution" - ) {{ $select.selected.name }} - ui-select-choices( - repeat="university in universities | filter: $select.search" - ) - span( - ng-bind="university.name" - ) - .affiliations-form-group( - ng-if="ui.isValidEmail && newAffiliation.university" - ) - ui-select( - ng-model="newAffiliation.role" - tagging - tagging-label="false" - ) - ui-select-match( - placeholder="Role" - ) {{ $select.selected }} - ui-select-choices( - repeat="role in roles | filter: $select.search" - ) - span( - ng-bind="role" - ) - - .affiliations-form-group( - ng-if="ui.isValidEmail && newAffiliation.university" - ) - ui-select( - ng-model="newAffiliation.department" - tagging - tagging-label="false" - ) - ui-select-match( - placeholder="Department" - ) {{ $select.selected }} - ui-select-choices( - repeat="department in departments | filter: $select.search" - ) - span( - ng-bind="department" - ) td - button.btn.btn-primary( - ng-disabled="affiliationsForm.$invalid || ui.isAddingNewEmail" + button.btn.btn-sm.btn-primary( + ng-disabled="affiliationsForm.$invalid || ui.isMakingRequest" ng-click="addNewEmail()" ) - span( - ng-if="!ui.isAddingNewEmail" - ) Add new email - span( - ng-if="ui.isAddingNewEmail" - ) - i.fa.fa-fw.fa-spin.fa-refresh - |  Adding... - hr \ No newline at end of file + | #{translate("add_new_email")} + tr.affiliations-table-highlighted-row( + ng-if="ui.isMakingRequest" + ) + td.text-center(colspan="3", ng-if="ui.isLoadingEmails") + i.fa.fa-fw.fa-spin.fa-refresh + |  #{translate("loading")}... + td.text-center(colspan="3", ng-if="ui.isResendingConfirmation") + i.fa.fa-fw.fa-spin.fa-refresh + |  #{translate("sending")}... + td.text-center(colspan="3", ng-if="!ui.isLoadingEmails && !ui.isResendingConfirmation") + i.fa.fa-fw.fa-spin.fa-refresh + |  #{translate("saving")}... + tr.affiliations-table-error-row( + ng-if="ui.hasError" + ) + td.text-center(colspan="3") + div + i.fa.fa-fw.fa-exclamation-triangle + span(ng-if="!ui.errorMessage")  #{translate("error_performing_request")} + span(ng-if="ui.errorMessage")  {{ui.errorMessage}} + + hr + +script(type="text/ng-template", id="affiliationFormTpl") + .affiliations-form-group( + ng-if="$ctrl.showUniversityAndCountry" + ) + ui-select( + ng-model="$ctrl.affiliationData.country" + ) + ui-select-match( + placeholder="Country" + ) {{ $select.selected.name }} + ui-select-choices( + repeat="country in $ctrl.countries | filter: $select.search" + ) + span( + ng-bind="country.name" + ) + .affiliations-form-group( + ng-if="$ctrl.showUniversityAndCountry" + ) + ui-select( + ng-model="$ctrl.affiliationData.university" + ng-disabled="!$ctrl.affiliationData.country" + tagging="$ctrladdUniversityToSelection" + tagging-label="false" + ) + ui-select-match( + placeholder="Institution" + ) {{ $select.selected.name }} + ui-select-choices( + repeat="university in $ctrl.universities | filter: $select.search" + ) + span( + ng-bind="university.name" + ) + .affiliations-form-group( + ng-if="$ctrl.showRoleAndDepartment" + ) + ui-select( + ng-model="$ctrl.affiliationData.role" + tagging + tagging-label="false" + ) + ui-select-match( + placeholder="Role" + ) {{ $select.selected }} + ui-select-choices( + repeat="role in $ctrl.roles | filter: $select.search" + ) + span( + ng-bind="role" + ) + + .affiliations-form-group( + ng-if="$ctrl.showRoleAndDepartment" + ) + ui-select( + ng-model="$ctrl.affiliationData.department" + tagging + tagging-label="false" + ) + ui-select-match( + placeholder="Department" + ) {{ $select.selected }} + ui-select-choices( + repeat="department in $ctrl.departments | filter: $select.search" + ) + span( + ng-bind="department" + ) diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index a9383714c9..59d6e0aa89 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -135,6 +135,8 @@ module.exports = settings = url: "http://#{process.env['FILESTORE_HOST'] or 'localhost'}:3009" clsi: url: "http://#{process.env['CLSI_HOST'] or 'localhost'}:3013" + # url: "http://#{process.env['CLSI_LB_HOST']}:3014" + backendGroupName: undefined templates: url: "http://#{process.env['TEMPLATES_HOST'] or 'localhost'}:3007" githubSync: @@ -277,10 +279,10 @@ module.exports = settings = # Third party services # -------------------- # - # ShareLaTeX's regular newsletter is managed by Markdown mail. Add your + # ShareLaTeX's regular newsletter is managed by mailchimp. Add your # credentials here to integrate with this. - # markdownmail: - # secret: "" + # mailchimp: + # api_key: "" # list_id: "" # # Fill in your unique token from various analytics services to enable @@ -339,7 +341,7 @@ module.exports = settings = # disablePerUserCompiles: true # Domain the client (pdfjs) should download the compiled pdf from - # pdfDownloadDomain: "http://compiles.sharelatex.test:3014" + # pdfDownloadDomain: "http://clsi-lb:3014" # Maximum size of text documents in the real-time editing system. max_doc_length: 2 * 1024 * 1024 # 2mb @@ -425,7 +427,7 @@ module.exports = settings = redirects: "/templates/index": "/templates/" - reloadModuleViewsOnEachRequest: true + reloadModuleViewsOnEachRequest: process.env['NODE_ENV'] != 'production' domainLicences: [ @@ -472,3 +474,14 @@ module.exports = settings = autoCompile: everyone: 100 standard: 25 + + # currentImage: "texlive-full:2017.1" + # imageRoot: "" # without any trailing slash + + # allowedImageNames: [ + # {imageName: 'texlive-full:2017.1', imageDesc: 'TeXLive 2017'} + # {imageName: 'wl_texlive:2018.1', imageDesc: 'Legacy OL TeXLive 2015'} + # {imageName: 'texlive-full:2016.1', imageDesc: 'Legacy SL TeXLive 2016'} + # {imageName: 'texlive-full:2015.1', imageDesc: 'Legacy SL TeXLive 2015'} + # {imageName: 'texlive-full:2014.2', imageDesc: 'Legacy SL TeXLive 2014.2'} + # ] \ No newline at end of file diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index afba1ccc60..ebf164f606 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -19,6 +19,7 @@ services: LINKED_URL_PROXY: 'http://localhost:6543' ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file,mendeley' SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee + NODE_ENV: production depends_on: - redis - mongo diff --git a/services/web/package.json b/services/web/package.json index ed0eb06455..8b8879375c 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -59,6 +59,7 @@ "lodash": "^4.13.1", "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master", "lynx": "0.1.1", + "mailchimp-api-v3": "^1.12.0", "marked": "^0.3.5", "method-override": "^2.3.3", "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1", @@ -98,7 +99,8 @@ "v8-profiler": "^5.2.3", "valid-url": "^1.0.9", "xml2js": "0.2.0", - "yauzl": "^2.8.0" + "yauzl": "^2.8.0", + "minimist": "1.2.0" }, "devDependencies": { "autoprefixer": "^6.6.1", diff --git a/services/web/public/coffee/base.coffee b/services/web/public/coffee/base.coffee index cb9e70b6bf..0a6f47f9fa 100644 --- a/services/web/public/coffee/base.coffee +++ b/services/web/public/coffee/base.coffee @@ -26,7 +26,29 @@ define [ baseUrl: window.sharelatex.sixpackDomain client_id: window.user_id }) - + + MathJax?.Hub?.Config( + extensions: ["Safe.js"] + messageStyle: "none" + imageFont:null + "HTML-CSS": + availableFonts: ["TeX"] + # MathJax's automatic font scaling does not work well when we render math + # that isn't yet on the page, so we disable it and set a global font + # scale factor + scale: 110 + matchFontHeight: false + TeX: + equationNumbers: { autoNumber: "AMS" } + useLabelIDs: false + skipStartupTypeset: true + tex2jax: + processEscapes: true + # Dollar delimiters are added by the mathjax directive + inlineMath: [ ["\\(","\\)"] ] + displayMath: [ ['$$','$$'], ["\\[","\\]"] ] + ) + App.run ($templateCache) -> # UI Select templates are hard-coded and use Glyphicon icons (which we don't import). # The line below simply overrides the hard-coded template with our own, which is diff --git a/services/web/public/coffee/directives/mathjax.coffee b/services/web/public/coffee/directives/mathjax.coffee new file mode 100644 index 0000000000..7124318214 --- /dev/null +++ b/services/web/public/coffee/directives/mathjax.coffee @@ -0,0 +1,21 @@ +define [ + "base" +], (App) -> + App.directive "mathjax", () -> + return { + link: (scope, element, attrs) -> + if attrs.delimiter != 'no-single-dollar' + inlineMathConfig = MathJax?.Hub?.config?.tex2jax.inlineMath + alreadyConfigured = _.find inlineMathConfig, (c) -> + c[0] == '$' and c[1] == '$' + + if !alreadyConfigured? + MathJax?.Hub?.Config( + tex2jax: + inlineMath: inlineMathConfig.concat([['$', '$']]) + ) + + setTimeout () -> + MathJax?.Hub?.Queue(["Typeset", MathJax?.Hub, element.get(0)]) + , 0 + } \ No newline at end of file diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 4b2c9ef440..7e25f9ca64 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -136,7 +136,7 @@ define [ ide.referencesSearchManager = new ReferencesManager(ide, $scope) ide.connectionManager = new ConnectionManager(ide, $scope) ide.fileTreeManager = new FileTreeManager(ide, $scope) - ide.editorManager = new EditorManager(ide, $scope) + ide.editorManager = new EditorManager(ide, $scope, localStorage) ide.onlineUsersManager = new OnlineUsersManager(ide, $scope) if window.data.useV2History ide.historyManager = new HistoryV2Manager(ide, $scope) diff --git a/services/web/public/coffee/ide/chat/directives/mathjax.coffee b/services/web/public/coffee/ide/chat/directives/mathjax.coffee deleted file mode 100644 index 08e167ee66..0000000000 --- a/services/web/public/coffee/ide/chat/directives/mathjax.coffee +++ /dev/null @@ -1,26 +0,0 @@ -define [ - "base" - "mathjax" -], (App) -> - mathjaxConfig = - messageStyle: "none" - imageFont:null - "HTML-CSS": { availableFonts: ["TeX"] }, - TeX: - equationNumbers: { autoNumber: "AMS" }, - useLabelIDs: false - tex2jax: - inlineMath: [ ['$','$'], ["\\(","\\)"] ], - displayMath: [ ['$$','$$'], ["\\[","\\]"] ], - processEscapes: true - skipStartupTypeset: true - - MathJax?.Hub?.Config(mathjaxConfig); - - App.directive "mathjax", () -> - return { - link: (scope, element, attrs) -> - setTimeout () -> - MathJax?.Hub?.Queue(["Typeset", MathJax?.Hub, element.get(0)]) - , 0 - } \ No newline at end of file diff --git a/services/web/public/coffee/ide/chat/index.coffee b/services/web/public/coffee/ide/chat/index.coffee index de9c46d62d..f2f146c745 100644 --- a/services/web/public/coffee/ide/chat/index.coffee +++ b/services/web/public/coffee/ide/chat/index.coffee @@ -2,6 +2,6 @@ define [ "ide/chat/controllers/ChatButtonController" "ide/chat/controllers/ChatController" "ide/chat/controllers/ChatMessageController" - "ide/chat/directives/mathjax" + "directives/mathjax" "filters/wrapLongWords" ], () -> \ No newline at end of file diff --git a/services/web/public/coffee/ide/directives/layout.coffee b/services/web/public/coffee/ide/directives/layout.coffee index b4e2e7f83d..8c30943f39 100644 --- a/services/web/public/coffee/ide/directives/layout.coffee +++ b/services/web/public/coffee/ide/directives/layout.coffee @@ -112,7 +112,8 @@ define [ element.layout().resizeAll() if attrs.resizeOn? - scope.$on attrs.resizeOn, () -> onExternalResize() + for event in attrs.resizeOn.split "," + scope.$on event, () -> onExternalResize() if hasCustomToggler state = element.layout().readState() diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index 7246e09b83..5130e4796d 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -6,7 +6,7 @@ define [ "ide/editor/controllers/SavingNotificationController" ], (Document) -> class EditorManager - constructor: (@ide, @$scope) -> + constructor: (@ide, @$scope, @localStorage) -> @$scope.editor = { sharejs_doc: null open_doc_id: null @@ -14,7 +14,7 @@ define [ opening: true trackChanges: false wantTrackChanges: false - showRichText: false + showRichText: @showRichText() } @$scope.$on "entity:selected", (event, entity) => @@ -41,6 +41,11 @@ define [ return if !value? @_syncTrackChangesState(@$scope.editor.sharejs_doc) + showRichText: () -> + if !window.richTextEnabled + return false + @localStorage("editor.mode.#{@$scope.project_id}") == 'rich-text' + autoOpenDoc: () -> open_doc_id = @ide.localStorage("doc.open_id.#{@$scope.project_id}") or diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index 617b41b845..a78df6a10a 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -10,13 +10,14 @@ define [ "ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter" "ide/editor/directives/aceEditor/highlights/HighlightsManager" "ide/editor/directives/aceEditor/cursor-position/CursorPositionManager" + "ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter" "ide/editor/directives/aceEditor/track-changes/TrackChangesManager" "ide/editor/directives/aceEditor/metadata/MetadataManager" "ide/metadata/services/metadata" "ide/graphics/services/graphics" "ide/preamble/services/preamble" - "ide/files/services/files" -], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) -> + "ide/files/services/files" +], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, CursorPositionAdapter, TrackChangesManager, MetadataManager) -> EditSession = ace.require('ace/edit_session').EditSession ModeList = ace.require('ace/ext/modelist') Vim = ace.require('ace/keyboard/vim').Vim @@ -108,7 +109,7 @@ define [ undoManager = new UndoManager(scope, editor, element) highlightsManager = new HighlightsManager(scope, editor, element) - cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage) + cursorPositionManager = new CursorPositionManager(scope, new CursorPositionAdapter(editor), localStorage) trackChangesManager = new TrackChangesManager(scope, editor, element) metadataManager = new MetadataManager(scope, editor, element, metadata) autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files) @@ -308,10 +309,12 @@ define [ scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) -> if old_sharejs_doc? + scope.$broadcast('beforeChangeDocument') detachFromAce(old_sharejs_doc) - if sharejs_doc? attachToAce(sharejs_doc) + if sharejs_doc? and old_sharejs_doc? + scope.$broadcast('afterChangeDocument') scope.$watch "text", (text) -> if text? @@ -380,6 +383,30 @@ define [ editor.off 'changeSession', onSessionChangeForSpellCheck editor.off 'nativecontextmenu', spellCheckManager.onContextMenu + onSessionChangeForCursorPosition = (e) -> + e.oldSession?.selection.off 'changeCursor', cursorPositionManager.onCursorChange + e.session.selection.on 'changeCursor', cursorPositionManager.onCursorChange + + onUnloadForCursorPosition = () -> + cursorPositionManager.onUnload(editor.getSession()) + + initCursorPosition = () -> + editor.on 'changeSession', onSessionChangeForCursorPosition + onSessionChangeForCursorPosition({ session: editor.getSession() }) # Force initial setup + $(window).on "unload", onUnloadForCursorPosition + + tearDownCursorPosition = () -> + editor.off 'changeSession', onSessionChangeForCursorPosition + $(window).off "unload", onUnloadForCursorPosition + + initCursorPosition() + + # Trigger the event once *only* - this is called after Ace is connected + # to the ShareJs instance but this event should only be triggered the + # first time the editor is opened. Not every time the docs opened + triggerEditorInitEvent = _.once () -> + scope.$broadcast('editorInit') + attachToAce = (sharejs_doc) -> lines = sharejs_doc.getSnapshot().split("\n") session = editor.getSession() @@ -425,6 +452,7 @@ define [ editor.initing = false # now ready to edit document editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false + triggerEditorInitEvent() initSpellCheck() resetScrollMargins() @@ -468,6 +496,7 @@ define [ editor.focus() detachFromAce = (sharejs_doc) -> + tearDownSpellCheck() sharejs_doc.detachFromAce() sharejs_doc.off "remoteop.recordRemote" @@ -488,9 +517,11 @@ define [ scope.$on '$destroy', () -> if scope.sharejsDoc? tearDownSpellCheck() + tearDownCursorPosition() detachFromAce(scope.sharejsDoc) session = editor.getSession() session?.destroy() + scope.eventsBridge.emit "aceScrollbarVisibilityChanged", false, 0 scope.$emit "#{scope.name}:inited", editor diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee index 638ab20b30..8fde8595cd 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee @@ -162,7 +162,8 @@ define [ cursorPosition = @editor.getCursorPosition() end = change.end {lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end) - if lineUpToCursor.match(/.*%.*/) + if ((i = lineUpToCursor.indexOf('%')) > -1 and lineUpToCursor[i-1] != '\\') + console.log lineUpToCursor, i return lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\" lastTwoChars = lineUpToCursor.slice(-2) diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter.coffee new file mode 100644 index 0000000000..d58f012b57 --- /dev/null +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter.coffee @@ -0,0 +1,32 @@ +define [ + "ide/editor/AceShareJsCodec" +], (AceShareJsCodec) -> + class CursorPositionAdapter + constructor: (@editor) -> + + getCursor: () -> + @editor.getCursorPosition() + + getEditorScrollPosition: () -> + @editor.getFirstVisibleRow() + + setCursor: (pos) -> + pos = pos.cursorPosition or { row: 0, column: 0 } + @editor.moveCursorToPosition(pos) + + setEditorScrollPosition: (pos) -> + pos = pos.firstVisibleLine or 0 + @editor.scrollToLine(pos) + + clearSelection: () -> + @editor.selection.clearSelection() + + gotoLine: (line, column) -> + @editor.gotoLine(line, column) + @editor.scrollToLine(line, true, true) # centre and animate + @editor.focus() + + gotoOffset: (offset) -> + lines = @editor.getSession().getDocument().getAllLines() + position = AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines) + @gotoLine(position.row + 1, position.column) diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee index 119aa471e1..bb957f8cf6 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee @@ -1,75 +1,63 @@ -define [ - "ide/editor/AceShareJsCodec" -], (AceShareJsCodec) -> +define [], () -> class CursorPositionManager - constructor: (@$scope, @editor, @element, @localStorage) -> + constructor: (@$scope, @adapter, @localStorage) -> + @$scope.$on 'editorInit', @jumpToPositionInNewDoc - onChangeCursor = (e) => - @emitCursorUpdateEvent(e) + @$scope.$on 'beforeChangeDocument', () => + @storeCursorPosition() + @storeFirstVisibleLine() - @editor.on "changeSession", (e) => - if e.oldSession? - @storeCursorPosition(e.oldSession) - @storeScrollTopPosition(e.oldSession) - - @doc_id = @$scope.sharejsDoc?.doc_id - - e.oldSession?.selection.off 'changeCursor', onChangeCursor - e.session.selection.on 'changeCursor', onChangeCursor - - setTimeout () => - @gotoStoredPosition() - , 0 - - $(window).on "unload", () => - @storeCursorPosition(@editor.getSession()) - @storeScrollTopPosition(@editor.getSession()) + @$scope.$on 'afterChangeDocument', @jumpToPositionInNewDoc @$scope.$on "#{@$scope.name}:gotoLine", (e, line, column) => if line? setTimeout () => - @gotoLine(line, column) + @adapter.gotoLine(line, column) , 10 # Hack: Must happen after @gotoStoredPosition @$scope.$on "#{@$scope.name}:gotoOffset", (e, offset) => if offset? setTimeout () => - @gotoOffset(offset) + @adapter.gotoOffset(offset) , 10 # Hack: Must happen after @gotoStoredPosition @$scope.$on "#{@$scope.name}:clearSelection", (e) => - @editor.selection.clearSelection() + @adapter.clearSelection() - storeScrollTopPosition: (session) -> + jumpToPositionInNewDoc: () => + @doc_id = @$scope.sharejsDoc?.doc_id + setTimeout () => + @gotoStoredPosition() + , 0 + + onUnload: () => + @storeCursorPosition() + @storeFirstVisibleLine() + + onCursorChange: () => + @emitCursorUpdateEvent() + + onSyncToPdf: () => + @$scope.$emit "cursor:#{@$scope.name}:syncToPdf" + + storeFirstVisibleLine: () -> if @doc_id? docPosition = @localStorage("doc.position.#{@doc_id}") || {} - docPosition.scrollTop = session.getScrollTop() + docPosition.firstVisibleLine = @adapter.getEditorScrollPosition() @localStorage("doc.position.#{@doc_id}", docPosition) - storeCursorPosition: (session) -> + storeCursorPosition: () -> if @doc_id? docPosition = @localStorage("doc.position.#{@doc_id}") || {} - docPosition.cursorPosition = session.selection.getCursor() + docPosition.cursorPosition = @adapter.getCursor() @localStorage("doc.position.#{@doc_id}", docPosition) - + emitCursorUpdateEvent: () -> - cursor = @editor.getCursorPosition() + cursor = @adapter.getCursor() @$scope.$emit "cursor:#{@$scope.name}:update", cursor gotoStoredPosition: () -> return if !@doc_id? pos = @localStorage("doc.position.#{@doc_id}") || {} - @ignoreCursorPositionChanges = true - @editor.moveCursorToPosition(pos.cursorPosition or {row: 0, column: 0}) - @editor.getSession().setScrollTop(pos.scrollTop or 0) - delete @ignoreCursorPositionChanges - - gotoLine: (line, column) -> - @editor.gotoLine(line, column) - @editor.scrollToLine(line,true,true) # centre and animate - @editor.focus() - - gotoOffset: (offset) -> - lines = @editor.getSession().getDocument().getAllLines() - position = AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines) - @gotoLine(position.row + 1, position.column) \ No newline at end of file + @adapter.setCursor(pos) + @adapter.setEditorScrollPosition(pos) diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index 7c8f476e39..3f231c046b 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -6,9 +6,14 @@ define [ "ide/history/controllers/HistoryV2ListController" "ide/history/controllers/HistoryV2DiffController" "ide/history/controllers/HistoryV2FileTreeController" + "ide/history/controllers/HistoryV2ToolbarController" + "ide/history/controllers/HistoryV2AddLabelModalController" + "ide/history/controllers/HistoryV2DeleteLabelModalController" "ide/history/directives/infiniteScroll" "ide/history/components/historyEntriesList" "ide/history/components/historyEntry" + "ide/history/components/historyLabelsList" + "ide/history/components/historyLabel" "ide/history/components/historyFileTree" "ide/history/components/historyFileEntity" ], (moment, ColorManager, displayNameForUser, HistoryViewModes) -> @@ -22,6 +27,9 @@ define [ @hide() else @show() + @ide.$timeout () => + @$scope.$broadcast "history:toggle" + , 0 @$scope.toggleHistoryViewMode = () => if @$scope.history.viewMode == HistoryViewModes.COMPARE @@ -30,6 +38,9 @@ define [ else @reset() @$scope.history.viewMode = HistoryViewModes.COMPARE + @ide.$timeout () => + @$scope.$broadcast "history:toggle" + , 0 @$scope.$watch "history.selection.updates", (updates) => if @$scope.history.viewMode == HistoryViewModes.COMPARE @@ -44,6 +55,18 @@ define [ else @reloadDiff() + @$scope.$watch "history.showOnlyLabels", (showOnlyLabels, prevVal) => + if showOnlyLabels? and showOnlyLabels != prevVal + if showOnlyLabels + @selectedLabelFromUpdatesSelection() + else + @$scope.history.selection.label = null + if @$scope.history.selection.updates.length == 0 + @autoSelectLastUpdate() + + @$scope.$watch "history.updates.length", () => + @recalculateSelectedUpdates() + show: () -> @$scope.ui.view = "history" @reset() @@ -60,6 +83,7 @@ define [ nextBeforeTimestamp: null atEnd: false selection: { + label: null updates: [] docs: {} pathname: null @@ -68,6 +92,9 @@ define [ toV: null } } + error: null + showOnlyLabels: false + labels: null files: [] diff: null # When history.viewMode == HistoryViewModes.COMPARE selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME @@ -81,10 +108,9 @@ define [ _csrf: window.csrfToken }) - loadFileTreeForUpdate: (update) -> - {fromV, toV} = update + loadFileTreeForVersion: (version) -> url = "/project/#{@$scope.project_id}/filetree/diff" - query = [ "from=#{toV}", "to=#{toV}" ] + query = [ "from=#{version}", "to=#{version}" ] url += "?" + query.join("&") @$scope.history.loadingFileTree = true @$scope.history.selectedFile = null @@ -113,6 +139,10 @@ define [ return if @$scope.history.updates.length == 0 @selectUpdate @$scope.history.updates[0] + autoSelectLastLabel: () -> + return if @$scope.history.labels.length == 0 + @selectLabel @$scope.history.labels[0] + selectUpdate: (update) -> selectedUpdateIndex = @$scope.history.updates.indexOf update if selectedUpdateIndex == -1 @@ -122,28 +152,107 @@ define [ update.selectedFrom = false @$scope.history.updates[selectedUpdateIndex].selectedTo = true @$scope.history.updates[selectedUpdateIndex].selectedFrom = true - @loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex] + @recalculateSelectedUpdates() + @loadFileTreeForVersion @$scope.history.updates[selectedUpdateIndex].toV + + selectedLabelFromUpdatesSelection: () -> + # Get the number of labels associated with the currently selected update + nSelectedLabels = @$scope.history.selection.updates?[0]?.labels?.length + # If the currently selected update has no labels, select the last one (version-wise) + if nSelectedLabels == 0 + @autoSelectLastLabel() + # If the update has one label, select it + else if nSelectedLabels == 1 + @selectLabel @$scope.history.selection.updates[0].labels[0] + # If there are multiple labels for the update, select the latest + else if nSelectedLabels > 1 + sortedLabels = @ide.$filter("orderBy")(@$scope.history.selection.updates[0].labels, '-created_at') + lastLabelFromUpdate = sortedLabels[0] + @selectLabel lastLabelFromUpdate + + selectLabel: (labelToSelect) -> + updateToSelect = null + + if @_isLabelSelected labelToSelect + # Label already selected + return + + for update in @$scope.history.updates + if update.toV == labelToSelect.version + updateToSelect = update + break + + @$scope.history.selection.label = labelToSelect + if updateToSelect? + @selectUpdate updateToSelect + else + @$scope.history.selection.updates = [] + @loadFileTreeForVersion labelToSelect.version + + recalculateSelectedUpdates: () -> + beforeSelection = true + afterSelection = false + @$scope.history.selection.updates = [] + for update in @$scope.history.updates + if update.selectedTo + inSelection = true + beforeSelection = false + + update.beforeSelection = beforeSelection + update.inSelection = inSelection + update.afterSelection = afterSelection + + if inSelection + @$scope.history.selection.updates.push update + + if update.selectedFrom + inSelection = false + afterSelection = true BATCH_SIZE: 10 fetchNextBatchOfUpdates: () -> - url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}" + updatesURL = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}" if @$scope.history.nextBeforeTimestamp? - url += "&before=#{@$scope.history.nextBeforeTimestamp}" + updatesURL += "&before=#{@$scope.history.nextBeforeTimestamp}" + labelsURL = "/project/#{@ide.project_id}/labels" + @$scope.history.loading = true @$scope.history.loadingFileTree = true - @ide.$http - .get(url) + + requests = + updates: @ide.$http.get updatesURL + + if !@$scope.history.labels? + requests.labels = @ide.$http.get labelsURL + + @ide.$q.all requests .then (response) => - { data } = response - @_loadUpdates(data.updates) - @$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp - if !data.nextBeforeTimestamp? + updatesData = response.updates.data + if response.labels? + @$scope.history.labels = @_sortLabelsByVersionAndDate response.labels.data + @_loadUpdates(updatesData.updates) + @$scope.history.nextBeforeTimestamp = updatesData.nextBeforeTimestamp + if !updatesData.nextBeforeTimestamp? @$scope.history.atEnd = true @$scope.history.loading = false + .catch (error) => + { status, statusText } = error + @$scope.history.error = { status, statusText } + @$scope.history.loading = false + @$scope.history.loadingFileTree = false + + _sortLabelsByVersionAndDate: (labels) -> + @ide.$filter("orderBy")(labels, [ '-version', '-created_at' ]) loadFileAtPointInTime: () -> pathname = @$scope.history.selection.pathname - toV = @$scope.history.selection.updates[0].toV + if @$scope.history.selection.updates?[0]? + toV = @$scope.history.selection.updates[0].toV + else if @$scope.history.selection.label? + toV = @$scope.history.selection.label.version + + if !toV? + return url = "/project/#{@$scope.project_id}/diff" query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"] url += "?" + query.join("&") @@ -199,6 +308,32 @@ define [ diff.loading = false diff.error = true + labelCurrentVersion: (labelComment) => + @_labelVersion labelComment, @$scope.history.selection.updates[0].toV + + deleteLabel: (label) => + url = "/project/#{@$scope.project_id}/labels/#{label.id}" + + @ide.$http({ + url, + method: "DELETE" + headers: + "X-CSRF-Token": window.csrfToken + }).then (response) => + @_deleteLabelLocally label + + _isLabelSelected: (label) -> + label.id == @$scope.history.selection.label?.id + + _deleteLabelLocally: (labelToDelete) -> + for update, i in @$scope.history.updates + if update.toV == labelToDelete.version + update.labels = _.filter update.labels, (label) -> + label.id != labelToDelete.id + break + @$scope.history.labels = _.filter @$scope.history.labels, (label) -> + label.id != labelToDelete.id + _parseDiff: (diff) -> if diff.binary return { binary: true } @@ -276,7 +411,27 @@ define [ if @$scope.history.viewMode == HistoryViewModes.COMPARE @autoSelectRecentUpdates() else - @autoSelectLastUpdate() + if @$scope.history.showOnlyLabels + @autoSelectLastLabel() + else + @autoSelectLastUpdate() + + _labelVersion: (comment, version) -> + url = "/project/#{@$scope.project_id}/labels" + @ide.$http + .post url, { + comment, + version, + _csrf: window.csrfToken + } + .then (response) => + @_addLabelToLocalUpdate response.data + + _addLabelToLocalUpdate: (label) => + localUpdate = _.find @$scope.history.updates, (update) -> update.toV == label.version + if localUpdate? + localUpdate.labels = @_sortLabelsByVersionAndDate localUpdate.labels.concat label + @$scope.history.labels = @_sortLabelsByVersionAndDate @$scope.history.labels.concat label _perDocSummaryOfUpdates: (updates) -> # Track current_pathname -> original_pathname diff --git a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee index 5022724714..de4e4f1b92 100644 --- a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee @@ -3,17 +3,35 @@ define [ ], (App) -> historyEntriesListController = ($scope, $element, $attrs) -> ctrl = @ + ctrl.$entryListViewportEl = null + _isEntryElVisible = ($entryEl) -> + entryElTop = $entryEl.offset().top + entryElBottom = entryElTop + $entryEl.outerHeight() + entryListViewportElTop = ctrl.$entryListViewportEl.offset().top + entryListViewportElBottom = entryListViewportElTop + ctrl.$entryListViewportEl.height() + return entryElTop >= entryListViewportElTop and entryElBottom <= entryListViewportElBottom; + _getScrollTopPosForEntry = ($entryEl) -> + halfViewportElHeight = ctrl.$entryListViewportEl.height() / 2 + return $entryEl.offset().top - halfViewportElHeight + ctrl.onEntryLinked = (entry, $entryEl) -> + if entry.selectedTo and entry.selectedFrom and !_isEntryElVisible $entryEl + $scope.$applyAsync () -> + ctrl.$entryListViewportEl.scrollTop _getScrollTopPosForEntry $entryEl + ctrl.$onInit = () -> + ctrl.$entryListViewportEl = $element.find "> .history-entries" return App.component "historyEntriesList", { bindings: entries: "<" + users: "<" loadEntries: "&" loadDisabled: "<" loadInitialize: "<" isLoading: "<" currentUser: "<" onEntrySelect: "&" + onLabelDelete: "&" controller: historyEntriesListController templateUrl: "historyEntriesListTpl" } diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee index e2692b7dee..6d5eed53d5 100644 --- a/services/web/public/coffee/ide/history/components/historyEntry.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee @@ -1,27 +1,44 @@ define [ "base" + "ide/colors/ColorManager" "ide/history/util/displayNameForUser" -], (App, displayNameForUser) -> - historyEntryController = ($scope, $element, $attrs) -> +], (App, ColorManager, displayNameForUser) -> + historyEntryController = ($scope, $element, $attrs, _) -> ctrl = @ + # This method (and maybe the one below) will be removed soon. User details data will be + # injected into the history API responses, so we won't need to fetch user data from other + # local data structures. + _getUserById = (id) -> + _.find ctrl.users, (user) -> + curUserId = user?._id or user?.id + curUserId == id ctrl.displayName = displayNameForUser + ctrl.displayNameById = (id) -> + displayNameForUser(_getUserById(id)) ctrl.getProjectOpDoc = (projectOp) -> if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }" else if projectOp.add? then "#{ projectOp.add.pathname}" else if projectOp.remove? then "#{ projectOp.remove.pathname}" ctrl.getUserCSSStyle = (user) -> - hue = user?.hue or 100 + curUserId = user?._id or user?.id + hue = ColorManager.getHueForUserId(curUserId) or 100 if ctrl.entry.inSelection color : "#FFF" else color: "hsl(#{ hue }, 70%, 50%)" + ctrl.$onInit = () -> + ctrl.historyEntriesList.onEntryLinked ctrl.entry, $element.find "> .history-entry" return App.component "historyEntry", { bindings: entry: "<" currentUser: "<" + users: "<" onSelect: "&" + onLabelDelete: "&" + require: + historyEntriesList: '^historyEntriesList' controller: historyEntryController templateUrl: "historyEntryTpl" } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/components/historyLabel.coffee b/services/web/public/coffee/ide/history/components/historyLabel.coffee new file mode 100644 index 0000000000..680d610d99 --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyLabel.coffee @@ -0,0 +1,20 @@ +define [ + "base" +], (App) -> + historyLabelController = ($scope, $element, $attrs, $filter, _) -> + ctrl = @ + ctrl.$onInit = () -> + ctrl.showTooltip ?= true + return + + App.component "historyLabel", { + bindings: + labelText: "<" + labelOwnerName: " + historyLabelsListController = ($scope, $element, $attrs) -> + ctrl = @ + # This method (and maybe the one below) will be removed soon. User details data will be + # injected into the history API responses, so we won't need to fetch user data from other + # local data structures. + ctrl.getUserById = (id) -> + _.find ctrl.users, (user) -> + curUserId = user?._id or user?.id + curUserId == id + ctrl.displayName = displayNameForUser + ctrl.getUserCSSStyle = (user, label) -> + curUserId = user?._id or user?.id + hue = ColorManager.getHueForUserId(curUserId) or 100 + if label.id == ctrl.selectedLabel?.id + color : "#FFF" + else + color: "hsl(#{ hue }, 70%, 50%)" + return + + App.component "historyLabelsList", { + bindings: + labels: "<" + users: "<" + currentUser: "<" + isLoading: "<" + selectedLabel: "<" + onLabelSelect: "&" + onLabelDelete: "&" + controller: historyLabelsListController + templateUrl: "historyLabelsListTpl" + } diff --git a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee index 4b0786d259..6c9890c6e5 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee @@ -3,9 +3,34 @@ define [ "ide/history/util/displayNameForUser" ], (App, displayNameForUser) -> - App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) -> + App.controller "HistoryListController", ["$scope", "$modal", "ide", ($scope, $modal, ide) -> $scope.hoveringOverListSelectors = false - + + $scope.projectUsers = [] + + $scope.$watch "project.members", (newVal) -> + if newVal? + $scope.projectUsers = newVal.concat $scope.project.owner + + # This method (and maybe the one below) will be removed soon. User details data will be + # injected into the history API responses, so we won't need to fetch user data from other + # local data structures. + _getUserById = (id) -> + _.find $scope.projectUsers, (user) -> + curUserId = user?._id or user?.id + curUserId == id + + $scope.getDisplayNameById = (id) -> + displayNameForUser(_getUserById(id)) + + $scope.deleteLabel = (labelDetails) -> + $modal.open( + templateUrl: "historyV2DeleteLabelModalTemplate" + controller: "HistoryV2DeleteLabelModalController" + resolve: + labelDetails: () -> labelDetails + ) + $scope.loadMore = () => ide.historyManager.fetchNextBatchOfUpdates() diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2AddLabelModalController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2AddLabelModalController.coffee new file mode 100644 index 0000000000..cbe72b0dce --- /dev/null +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2AddLabelModalController.coffee @@ -0,0 +1,29 @@ +define [ + "base", +], (App) -> + App.controller "HistoryV2AddLabelModalController", ["$scope", "$modalInstance", "ide", "update", ($scope, $modalInstance, ide, update) -> + $scope.update = update + $scope.inputs = + labelName: null + $scope.state = + inflight: false + error: false + + $modalInstance.opened.then () -> + $scope.$applyAsync () -> + $scope.$broadcast "open" + + $scope.addLabelModalFormSubmit = () -> + $scope.state.inflight = true + ide.historyManager.labelCurrentVersion $scope.inputs.labelName + .then (response) -> + $scope.state.inflight = false + $modalInstance.close() + .catch (response) -> + { data, status } = response + $scope.state.inflight = false + if status == 400 + $scope.state.error = { message: data } + else + $scope.state.error = true + ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DeleteLabelModalController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DeleteLabelModalController.coffee new file mode 100644 index 0000000000..6001c33762 --- /dev/null +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DeleteLabelModalController.coffee @@ -0,0 +1,23 @@ +define [ + "base", +], (App) -> + App.controller "HistoryV2DeleteLabelModalController", ["$scope", "$modalInstance", "ide", "labelDetails", ($scope, $modalInstance, ide, labelDetails) -> + $scope.labelDetails = labelDetails + $scope.state = + inflight: false + error: false + + $scope.deleteLabel = () -> + $scope.state.inflight = true + ide.historyManager.deleteLabel labelDetails + .then (response) -> + $scope.state.inflight = false + $modalInstance.close() + .catch (response) -> + { data, status } = response + $scope.state.inflight = false + if status == 400 + $scope.state.error = { message: data } + else + $scope.state.error = true + ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee index eaf7fbc884..adff4e1cb0 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee @@ -3,74 +3,31 @@ define [ "ide/history/util/displayNameForUser" ], (App, displayNameForUser) -> - App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) -> + App.controller "HistoryV2ListController", ["$scope", "$modal", "ide", ($scope, $modal, ide) -> $scope.hoveringOverListSelectors = false + $scope.listConfig = + showOnlyLabelled: false + $scope.projectUsers = [] + + $scope.$watch "project.members", (newVal) -> + if newVal? + $scope.projectUsers = newVal.concat $scope.project.owner + $scope.loadMore = () => ide.historyManager.fetchNextBatchOfUpdates() $scope.handleEntrySelect = (entry) -> - # $scope.$applyAsync () -> ide.historyManager.selectUpdate(entry) - $scope.recalculateSelectedUpdates() - $scope.recalculateSelectedUpdates = () -> - beforeSelection = true - afterSelection = false - $scope.history.selection.updates = [] - for update in $scope.history.updates - if update.selectedTo - inSelection = true - beforeSelection = false - - update.beforeSelection = beforeSelection - update.inSelection = inSelection - update.afterSelection = afterSelection - - if inSelection - $scope.history.selection.updates.push update - - if update.selectedFrom - inSelection = false - afterSelection = true - - $scope.recalculateHoveredUpdates = () -> - hoverSelectedFrom = false - hoverSelectedTo = false - for update in $scope.history.updates - # Figure out whether the to or from selector is hovered over - if update.hoverSelectedFrom - hoverSelectedFrom = true - if update.hoverSelectedTo - hoverSelectedTo = true - - if hoverSelectedFrom - # We want to 'hover select' everything between hoverSelectedFrom and selectedTo - inHoverSelection = false - for update in $scope.history.updates - if update.selectedTo - update.hoverSelectedTo = true - inHoverSelection = true - update.inHoverSelection = inHoverSelection - if update.hoverSelectedFrom - inHoverSelection = false - if hoverSelectedTo - # We want to 'hover select' everything between hoverSelectedTo and selectedFrom - inHoverSelection = false - for update in $scope.history.updates - if update.hoverSelectedTo - inHoverSelection = true - update.inHoverSelection = inHoverSelection - if update.selectedFrom - update.hoverSelectedFrom = true - inHoverSelection = false - - $scope.resetHoverState = () -> - for update in $scope.history.updates - delete update.hoverSelectedFrom - delete update.hoverSelectedTo - delete update.inHoverSelection - - $scope.$watch "history.updates.length", () -> - $scope.recalculateSelectedUpdates() + $scope.handleLabelSelect = (label) -> + ide.historyManager.selectLabel(label) + + $scope.handleLabelDelete = (labelDetails) -> + $modal.open( + templateUrl: "historyV2DeleteLabelModalTemplate" + controller: "HistoryV2DeleteLabelModalController" + resolve: + labelDetails: () -> labelDetails + ) ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ToolbarController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ToolbarController.coffee new file mode 100644 index 0000000000..3feb9f8a7d --- /dev/null +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ToolbarController.coffee @@ -0,0 +1,12 @@ +define [ + "base", +], (App) -> + App.controller "HistoryV2ToolbarController", ["$scope", "$modal", "ide", ($scope, $modal, ide) -> + $scope.showAddLabelDialog = () -> + $modal.open( + templateUrl: "historyV2AddLabelModalTemplate" + controller: "HistoryV2AddLabelModalController" + resolve: + update: () -> $scope.history.selection.updates[0] + ) + ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee index 77a94a564b..40264e1be0 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -93,7 +93,7 @@ define [ # don't want to compile as soon as it is fixed, so reset the timeout. $scope.startedTryingAutoCompileAt = Date.now() $scope.docLastChangedAt = Date.now() - if autoCompileLintingError + if autoCompileLintingError and $scope.stop_on_validation_error return # If there's a longish compile, don't compile immediately after if user is still typing @@ -224,6 +224,13 @@ define [ _csrf: window.csrfToken }, {params: params} + buildPdfDownloadUrl = (pdfDownloadDomain, path)-> + #we only download builds from compiles server for security reasons + if pdfDownloadDomain? and path.indexOf("build") != -1 + return "#{pdfDownloadDomain}#{path}" + else + return path + parseCompileResponse = (response) -> # keep last url @@ -244,11 +251,7 @@ define [ $scope.pdf.compileInProgress = false $scope.pdf.autoCompileDisabled = false - buildPdfDownloadUrl = (path)-> - if pdfDownloadDomain? - return "#{pdfDownloadDomain}#{path}" - else - return path + # make a cache to look up files by name fileByPath = {} if response?.outputFiles? @@ -267,24 +270,24 @@ define [ if response.status == "timedout" $scope.pdf.view = 'errors' $scope.pdf.timedout = true - fetchLogs(fileByPath) + fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain}) else if response.status == "terminated" $scope.pdf.view = 'errors' $scope.pdf.compileTerminated = true - fetchLogs(fileByPath) + fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain}) else if response.status in ["validation-fail", "validation-pass"] $scope.pdf.view = 'pdf' - $scope.pdf.url = buildPdfDownloadUrl last_pdf_url + $scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, last_pdf_url $scope.shouldShowLogs = true $scope.pdf.failedCheck = true if response.status is "validation-fail" event_tracking.sendMB "syntax-check-#{response.status}" - fetchLogs(fileByPath, { validation: true }) + fetchLogs(fileByPath, { validation: true, pdfDownloadDomain:pdfDownloadDomain}) else if response.status == "exited" $scope.pdf.view = 'pdf' $scope.pdf.compileExited = true - $scope.pdf.url = buildPdfDownloadUrl last_pdf_url + $scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, last_pdf_url $scope.shouldShowLogs = true - fetchLogs(fileByPath) + fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain}) else if response.status == "autocompile-backoff" if $scope.pdf.isAutoCompileOnLoad # initial autocompile $scope.pdf.view = 'uncompiled' @@ -300,7 +303,7 @@ define [ $scope.pdf.view = 'errors' $scope.pdf.failure = true $scope.shouldShowLogs = true - fetchLogs(fileByPath) + fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain}) else if response.status == 'clsi-maintenance' $scope.pdf.view = 'errors' $scope.pdf.clsiMaintenance = true @@ -320,12 +323,12 @@ define [ # define the base url. if the pdf file has a build number, pass it to the clsi in the url if fileByPath['output.pdf']?.url? - $scope.pdf.url = buildPdfDownloadUrl fileByPath['output.pdf'].url + $scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, fileByPath['output.pdf'].url else if fileByPath['output.pdf']?.build? build = fileByPath['output.pdf'].build - $scope.pdf.url = buildPdfDownloadUrl "/project/#{$scope.project_id}/build/#{build}/output/output.pdf" + $scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, "/project/#{$scope.project_id}/build/#{build}/output/output.pdf" else - $scope.pdf.url = buildPdfDownloadUrl "/project/#{$scope.project_id}/output/output.pdf" + $scope.pdf.url = buildPdfDownloadUrl pdfDownloadDomain, "/project/#{$scope.project_id}/output/output.pdf" # check if we need to bust cache (build id is unique so don't need it in that case) if not fileByPath['output.pdf']?.build? qs.cache_bust = "#{Date.now()}" @@ -335,7 +338,7 @@ define [ qs.popupDownload = true $scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs) - fetchLogs(fileByPath) + fetchLogs(fileByPath, {pdfDownloadDomain:pdfDownloadDomain}) IGNORE_FILES = ["output.fls", "output.fdb_latexmk"] $scope.pdf.outputFiles = [] @@ -384,6 +387,7 @@ define [ # check if we need to bust cache (build id is unique so don't need it in that case) if not file?.build? opts.params.cache_bust = "#{Date.now()}" + opts.url = buildPdfDownloadUrl options.pdfDownloadDomain, opts.url return $http(opts) # accumulate the log entries @@ -718,6 +722,8 @@ define [ .then (highlights) -> $scope.pdf.highlights = highlights + ide.$scope.$on "cursor:editor:syncToPdf", $scope.syncToPdf + $scope.syncToCode = () -> synctex .syncToCode($scope.pdf.position, includeVisualOffset: true, fromPdfPosition: true) diff --git a/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee b/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee index 430c5cd246..2eabee764c 100644 --- a/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee +++ b/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee @@ -164,7 +164,7 @@ define [ updateScrollbar() updateScrollbar = () -> - if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE + if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE and !$scope.editor.showRichText $reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px" else $reviewPanelEl.css "right", "0" diff --git a/services/web/public/coffee/ide/services/ide.coffee b/services/web/public/coffee/ide/services/ide.coffee index 6462859df2..24805b6270 100644 --- a/services/web/public/coffee/ide/services/ide.coffee +++ b/services/web/public/coffee/ide/services/ide.coffee @@ -3,11 +3,13 @@ define [ ], (App) -> # We create and provide this as service so that we can access the global ide # from within other parts of the angular app. - App.factory "ide", ["$http", "queuedHttp", "$modal", "$q", ($http, queuedHttp, $modal, $q) -> + App.factory "ide", ["$http", "queuedHttp", "$modal", "$q", "$filter", "$timeout", ($http, queuedHttp, $modal, $q, $filter, $timeout) -> ide = {} ide.$http = $http ide.queuedHttp = queuedHttp ide.$q = $q + ide.$filter = $filter + ide.$timeout = $timeout @recentEvents = [] ide.pushEvent = (type, meta = {}) => diff --git a/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee b/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee index 9920372aab..2e8c09739e 100644 --- a/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee +++ b/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee @@ -67,6 +67,11 @@ define [ if oldCompiler? and compiler != oldCompiler settings.saveProjectSettings({compiler: compiler}) + $scope.$watch "project.imageName", (imageName, oldImageName) => + return if @ignoreUpdates + if oldImageName? and imageName != oldImageName + settings.saveProjectSettings({imageName: imageName}) + $scope.$watch "project.rootDoc_id", (rootDoc_id, oldRootDoc_id) => return if @ignoreUpdates # don't save on initialisation, Angular passes oldRootDoc_id as @@ -83,6 +88,12 @@ define [ $scope.project.compiler = compiler delete @ignoreUpdates + ide.socket.on "imageNameUpdated", (imageName) => + @ignoreUpdates = true + $scope.$apply () => + $scope.project.imageName = imageName + delete @ignoreUpdates + ide.socket.on "spellCheckLanguageUpdated", (languageCode) => @ignoreUpdates = true $scope.$apply () => diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index 80e637d705..fc62c6bc38 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -20,9 +20,11 @@ define [ "main/subscription/team-invite-controller" "main/contact-us" "main/learn" + "main/affiliations/components/affiliationForm" "main/affiliations/controllers/UserAffiliationsController" "main/affiliations/factories/UserAffiliationsDataService" "main/keys" + "main/cms/index" "analytics/AbTestingManager" "directives/asyncForm" "directives/stopPropagation" diff --git a/services/web/public/coffee/main/account-settings.coffee b/services/web/public/coffee/main/account-settings.coffee index 82d323110e..cac35394a1 100644 --- a/services/web/public/coffee/main/account-settings.coffee +++ b/services/web/public/coffee/main/account-settings.coffee @@ -1,7 +1,7 @@ define [ "base" ], (App) -> - App.controller "AccountSettingsController", ["$scope", "$http", "$modal", "event_tracking", ($scope, $http, $modal, event_tracking) -> + App.controller "AccountSettingsController", ["$scope", "$http", "$modal", "event_tracking", "UserAffiliationsDataService", ($scope, $http, $modal, event_tracking, UserAffiliationsDataService) -> $scope.subscribed = true $scope.unsubscribe = () -> @@ -21,8 +21,14 @@ define [ $scope.deleteAccount = () -> modalInstance = $modal.open( templateUrl: "deleteAccountModalTemplate" - controller: "DeleteAccountModalController", - scope: $scope + controller: "DeleteAccountModalController" + resolve: + userDefaultEmail: () -> + UserAffiliationsDataService + .getUserDefaultEmail() + .then (defaultEmailDetails) -> + return defaultEmailDetails?.email or null + .catch () -> null ) $scope.upgradeIntegration = (service) -> @@ -30,8 +36,8 @@ define [ ] App.controller "DeleteAccountModalController", [ - "$scope", "$modalInstance", "$timeout", "$http", - ($scope, $modalInstance, $timeout, $http) -> + "$scope", "$modalInstance", "$timeout", "$http", "userDefaultEmail", + ($scope, $modalInstance, $timeout, $http, userDefaultEmail) -> $scope.state = isValid : false deleteText: "" @@ -46,7 +52,7 @@ define [ , 700 $scope.checkValidation = -> - $scope.state.isValid = $scope.state.deleteText == $scope.email and $scope.state.password.length > 0 + $scope.state.isValid = userDefaultEmail? and $scope.state.deleteText == userDefaultEmail and $scope.state.password.length > 0 $scope.delete = () -> $scope.state.inflight = true diff --git a/services/web/public/coffee/main/affiliations/components/affiliationForm.coffee b/services/web/public/coffee/main/affiliations/components/affiliationForm.coffee new file mode 100644 index 0000000000..1c0c7d6229 --- /dev/null +++ b/services/web/public/coffee/main/affiliations/components/affiliationForm.coffee @@ -0,0 +1,52 @@ +define [ + "base" +], (App) -> + affiliationFormController = ($scope, $element, $attrs, UserAffiliationsDataService) -> + ctrl = @ + ctrl.roles = [] + ctrl.departments = [] + ctrl.countries = [] + ctrl.universities = [] + _defaultDepartments = [] + + ctrl.addUniversityToSelection = (universityName) -> + { name: universityName, isUserSuggested: true } + # Populates the countries dropdown + UserAffiliationsDataService + .getCountries() + .then (countries) -> ctrl.countries = countries + # Populates the roles dropdown + UserAffiliationsDataService + .getDefaultRoleHints() + .then (roles) -> ctrl.roles = roles + # Fetches the default department hints + UserAffiliationsDataService + .getDefaultDepartmentHints() + .then (departments) -> + _defaultDepartments = departments + # Populates the universities dropdown (after selecting a country) + $scope.$watch "$ctrl.affiliationData.country", (newSelectedCountry, prevSelectedCountry) -> + if newSelectedCountry? and newSelectedCountry != prevSelectedCountry + ctrl.affiliationData.university = null + ctrl.affiliationData.role = null + ctrl.affiliationData.department = null + UserAffiliationsDataService + .getUniversitiesFromCountry(newSelectedCountry) + .then (universities) -> ctrl.universities = universities + # Populates the departments dropdown (after selecting a university) + $scope.$watch "$ctrl.affiliationData.university", (newSelectedUniversity, prevSelectedUniversity) -> + if newSelectedUniversity? and newSelectedUniversity != prevSelectedUniversity and newSelectedUniversity.departments?.length > 0 + ctrl.departments = _.uniq newSelectedUniversity.departments + else + ctrl.departments = _defaultDepartments + + return + + App.component "affiliationForm", { + bindings: + affiliationData: "=" + showUniversityAndCountry: "<" + showRoleAndDepartment: "<" + controller: affiliationFormController + templateUrl: "affiliationFormTpl" + } \ No newline at end of file diff --git a/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee b/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee index a8cf710afc..14ea1a7d29 100644 --- a/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee +++ b/services/web/public/coffee/main/affiliations/controllers/UserAffiliationsController.coffee @@ -3,12 +3,6 @@ define [ ], (App) -> App.controller "UserAffiliationsController", ["$scope", "UserAffiliationsDataService", "$q", "_", ($scope, UserAffiliationsDataService, $q, _) -> $scope.userEmails = [] - $scope.countries = [] - $scope.universities = [] - $scope.roles = [] - $scope.departments = [] - - _defaultDepartments = [] LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/ EMAIL_REGEX = /^([A-Za-z0-9_\-\.]+)@([^\.]+)\.([A-Za-z0-9_\-\.]+)([^\.])$/ @@ -20,9 +14,6 @@ define [ else { local: null, domain: null } - $scope.addUniversityToSelection = (universityName) -> - { name: universityName, isUserSuggested: true } - $scope.getEmailSuggestion = (userInput) -> userInputLocalAndDomain = _matchLocalAndDomain(userInput) $scope.ui.isValidEmail = EMAIL_REGEX.test userInput @@ -55,11 +46,40 @@ define [ $scope.newAffiliation.department = null $scope.ui.showManualUniversitySelectionUI = true + $scope.changeAffiliation = (userEmail) -> + if userEmail.affiliation?.institution?.id? + UserAffiliationsDataService.getUniversityDetails userEmail.affiliation.institution.id + .then (universityDetails) -> $scope.affiliationToChange.university = universityDetails + + $scope.affiliationToChange.email = userEmail.email + $scope.affiliationToChange.role = userEmail.affiliation.role + $scope.affiliationToChange.department = userEmail.affiliation.department + + $scope.saveAffiliationChange = (userEmail) -> + userEmail.affiliation.role = $scope.affiliationToChange.role + userEmail.affiliation.department = $scope.affiliationToChange.department + _resetAffiliationToChange() + _monitorRequest( + UserAffiliationsDataService + .addRoleAndDepartment( + userEmail.email, + userEmail.affiliation.role, + userEmail.affiliation.department + ) + ) + .then () -> + setTimeout () -> _getUserEmails() + + $scope.cancelAffiliationChange = (email) -> + _resetAffiliationToChange() + + $scope.isChangingAffiliation = (email) -> + $scope.affiliationToChange.email == email + $scope.showAddEmailForm = () -> $scope.ui.showAddEmailUI = true $scope.addNewEmail = () -> - $scope.ui.isAddingNewEmail = true if !$scope.newAffiliation.university? addEmailPromise = UserAffiliationsDataService .addUserEmail $scope.newAffiliation.email @@ -81,86 +101,104 @@ define [ $scope.newAffiliation.role, $scope.newAffiliation.department ) - addEmailPromise.then () -> - _reset() - _getUserEmails() - $scope.setDefaultUserEmail = (email) -> - $scope.ui.isLoadingEmails = true - UserAffiliationsDataService - .setDefaultUserEmail email - .then () -> _getUserEmails() + $scope.ui.isAddingNewEmail = true + $scope.ui.showAddEmailUI = false + _monitorRequest(addEmailPromise) + .then () -> + _resetNewAffiliation() + _resetAddingEmail() + setTimeout () -> _getUserEmails() + .finally () -> + $scope.ui.isAddingNewEmail = false - $scope.removeUserEmail = (email) -> - $scope.ui.isLoadingEmails = true - UserAffiliationsDataService - .removeUserEmail email - .then () -> _getUserEmails() + $scope.setDefaultUserEmail = (userEmail) -> + _monitorRequest( + UserAffiliationsDataService + .setDefaultUserEmail userEmail.email + ) + .then () -> + for email in $scope.userEmails or [] + email.default = false + userEmail.default = true - $scope.getDepartments = () -> - if $scope.newAffiliation.university?.departments.length > 0 - _.uniq $scope.newAffiliation.university.departments - else - UserAffiliationsDataService.getDefaultDepartmentHints() + $scope.removeUserEmail = (userEmail) -> + $scope.userEmails = $scope.userEmails.filter (ue) -> ue != userEmail + _monitorRequest( + UserAffiliationsDataService + .removeUserEmail userEmail.email + ) - _reset = () -> + $scope.resendConfirmationEmail = (userEmail) -> + $scope.ui.isResendingConfirmation = true + _monitorRequest( + UserAffiliationsDataService + .resendConfirmationEmail userEmail.email + ) + .finally () -> + $scope.ui.isResendingConfirmation = false + + $scope.acknowledgeError = () -> + _reset() + _getUserEmails() + + _resetAffiliationToChange = () -> + $scope.affiliationToChange = + email: "" + university: null + role: null + department: null + + _resetNewAffiliation = () -> $scope.newAffiliation = email: "" country: null university: null role: null department: null + + _resetAddingEmail = () -> + $scope.ui.showAddEmailUI = false + $scope.ui.isValidEmail = false + $scope.ui.isBlacklistedEmail = false + $scope.ui.showManualUniversitySelectionUI = false + + _reset = () -> $scope.ui = - showManualUniversitySelectionUI: false + hasError: false + errorMessage: "" + showChangeAffiliationUI: false + isMakingRequest: false isLoadingEmails: false isAddingNewEmail: false - showAddEmailUI: false - isValidEmail: false - isBlacklistedEmail: false + isResendingConfirmation: false + _resetAffiliationToChange() + _resetNewAffiliation() + _resetAddingEmail() _reset() + _monitorRequest = (promise) -> + $scope.ui.hasError = false + $scope.ui.isMakingRequest = true + promise + .catch (response) -> + $scope.ui.hasError = true + $scope.ui.errorMessage = response?.data?.message + .finally () -> + $scope.ui.isMakingRequest = false + return promise + # Populates the emails table _getUserEmails = () -> $scope.ui.isLoadingEmails = true - UserAffiliationsDataService - .getUserEmails() + _monitorRequest( + UserAffiliationsDataService + .getUserEmails() + ) .then (emails) -> $scope.userEmails = emails + .finally () -> $scope.ui.isLoadingEmails = false _getUserEmails() - # Populates the countries dropdown - UserAffiliationsDataService - .getCountries() - .then (countries) -> $scope.countries = countries - - # Populates the roles dropdown - UserAffiliationsDataService - .getDefaultRoleHints() - .then (roles) -> $scope.roles = roles - - # Fetches the default department hints - UserAffiliationsDataService - .getDefaultDepartmentHints() - .then (departments) -> - _defaultDepartments = departments - - # Populates the universities dropdown (after selecting a country) - $scope.$watch "newAffiliation.country", (newSelectedCountry, prevSelectedCountry) -> - if newSelectedCountry? and newSelectedCountry != prevSelectedCountry - $scope.newAffiliation.university = null - $scope.newAffiliation.role = null - $scope.newAffiliation.department = null - UserAffiliationsDataService - .getUniversitiesFromCountry(newSelectedCountry) - .then (universities) -> $scope.universities = universities - - # Populates the departments dropdown (after selecting a university) - $scope.$watch "newAffiliation.university", (newSelectedUniversity, prevSelectedUniversity) -> - if newSelectedUniversity? and newSelectedUniversity != prevSelectedUniversity - if newSelectedUniversity.departments?.length > 0 - $scope.departments = _.uniq newSelectedUniversity.departments - else - $scope.departments = _defaultDepartments - ] \ No newline at end of file diff --git a/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee index ba558eb595..ca1ffef168 100644 --- a/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee +++ b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee @@ -27,10 +27,14 @@ define [ getDefaultDepartmentHints = () -> $q.resolve defaultDepartmentHints - getUserEmails = () -> + getUserEmails = () -> $http.get "/user/emails" .then (response) -> response.data + getUserDefaultEmail = () -> + getUserEmails().then (userEmails) -> + _.find userEmails, (userEmail) -> userEmail.default + getUniversitiesFromCountry = (country) -> if universities[country.code]? universitiesFromCountry = universities[country.code] @@ -53,6 +57,10 @@ define [ else $q.reject null + getUniversityDetails = (universityId) -> + $http.get "/institutions/list/#{ universityId }" + .then (response) -> response.data + addUserEmail = (email) -> $http.post "/user/emails", { email, @@ -80,8 +88,17 @@ define [ _csrf: window.csrfToken } + addRoleAndDepartment = (email, role, department) -> + $http.post "/user/emails/endorse", { + email, + role, + department, + _csrf: window.csrfToken + } + setDefaultUserEmail = (email) -> $http.post "/user/emails/default", { + email, _csrf: window.csrfToken } @@ -91,6 +108,12 @@ define [ _csrf: window.csrfToken } + resendConfirmationEmail = (email) -> + $http.post "/user/emails/resend_confirmation", { + email, + _csrf: window.csrfToken + } + isDomainBlacklisted = (domain) -> domain.toLowerCase() of domainsBlackList @@ -99,13 +122,17 @@ define [ getDefaultRoleHints getDefaultDepartmentHints getUserEmails + getUserDefaultEmail getUniversitiesFromCountry getUniversityDomainFromPartialDomainInput + getUniversityDetails addUserEmail addUserAffiliationWithUnknownUniversity addUserAffiliation + addRoleAndDepartment setDefaultUserEmail removeUserEmail + resendConfirmationEmail isDomainBlacklisted } ] \ No newline at end of file diff --git a/services/web/public/coffee/main/learn.coffee b/services/web/public/coffee/main/learn.coffee index 375c2f0365..8ed54cf57e 100644 --- a/services/web/public/coffee/main/learn.coffee +++ b/services/web/public/coffee/main/learn.coffee @@ -1,5 +1,6 @@ define [ "base" + "directives/mathjax" "services/algolia-search" ], (App) -> @@ -55,4 +56,4 @@ define [ hits = _.map response.hits, buildHitViewModel updateHits hits - + App.controller "LearnController", () -> diff --git a/services/web/public/coffee/main/new-subscription.coffee b/services/web/public/coffee/main/new-subscription.coffee index 7851171524..e8294a5885 100644 --- a/services/web/public/coffee/main/new-subscription.coffee +++ b/services/web/public/coffee/main/new-subscription.coffee @@ -4,20 +4,21 @@ define [ "libs/recurly-4.8.5" ], (App)-> - App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking, ccUtils)-> + App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking, ccUtils, ipCookie)-> throw new Error("Recurly API Library Missing.") if typeof recurly is "undefined" $scope.currencyCode = MultiCurrencyPricing.currencyCode $scope.plans = MultiCurrencyPricing.plans $scope.planCode = window.plan_code + $scope.plansVariant = ipCookie('plansVariant') $scope.switchToStudent = ()-> currentPlanCode = window.plan_code planCode = currentPlanCode.replace('collaborator', 'student') - event_tracking.sendMB 'subscription-form-switch-to-student', { plan: window.plan_code } + event_tracking.sendMB 'subscription-form-switch-to-student', { plan: window.plan_code, variant: $scope.plansVariant } window.location = "/user/subscription/new?planCode=#{planCode}¤cy=#{$scope.currencyCode}&cc=#{$scope.data.coupon}" - event_tracking.sendMB "subscription-form", { plan : window.plan_code } + event_tracking.sendMB "subscription-form", { plan : window.plan_code, variant: $scope.plansVariant } $scope.paymentMethod = value: "credit_card" @@ -143,13 +144,14 @@ define [ currencyCode : postData.subscriptionDetails.currencyCode, plan_code : postData.subscriptionDetails.plan_code, coupon_code : postData.subscriptionDetails.coupon_code, - isPaypal : postData.subscriptionDetails.isPaypal + isPaypal : postData.subscriptionDetails.isPaypal, + variant : $scope.plansVariant } $http.post("/user/subscription/create", postData) .then ()-> - event_tracking.sendMB "subscription-submission-success" + event_tracking.sendMB "subscription-submission-success", { variant: $scope.plansVariant } window.location.href = "/user/subscription/thank-you" .catch ()-> $scope.processing = false @@ -234,7 +236,4 @@ define [ {code:'VU',name:'Vanuatu'},{code:'VA',name:'Vatican City'},{code:'VE',name:'Venezuela'},{code:'VN',name:'Vietnam'}, {code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'}, {code:'ZM',name:'Zambia'},{code:'AX',name:'Åland Islandscode:'} - ] - - sixpack.participate 'plans', ['default', 'more-details'], (chosenVariation, rawResponse)-> - $scope.plansVariant = chosenVariation \ No newline at end of file + ] \ No newline at end of file diff --git a/services/web/public/coffee/main/plans.coffee b/services/web/public/coffee/main/plans.coffee index 1637d79374..f13dff689f 100644 --- a/services/web/public/coffee/main/plans.coffee +++ b/services/web/public/coffee/main/plans.coffee @@ -145,15 +145,21 @@ define [ } - App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter) -> + App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter, ipCookie) -> $scope.showPlans = false $scope.shouldABTestPlans = window.shouldABTestPlans if $scope.shouldABTestPlans sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)-> - $scope.plansVariant = chosenVariation - event_tracking.send 'subscription-funnel', 'plans-page-loaded', chosenVariation + if rawResponse?.status != 'failed' + $scope.plansVariant = chosenVariation + expiration = new Date(); + expiration.setDate(expiration.getDate() + 5); + ipCookie('plansVariant', chosenVariation, {expires: expiration}) + event_tracking.send 'subscription-funnel', 'plans-page-loaded', chosenVariation + else + $scope.timeout = true $scope.showPlans = true @@ -184,9 +190,9 @@ define [ if $scope.ui.view == "annual" plan = "#{plan}_annual" plan = eventLabel(plan, location) - event_tracking.sendMB 'plans-page-start-trial', {plan} + event_tracking.sendMB 'plans-page-start-trial', {plan, variant: $scope.plansVariant} event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan - if $scope.shouldABTestPlans + if $scope.plansVariant sixpack.convert 'plans-details' $scope.switchToMonthly = (e, location) -> diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee index 609a16cb77..42a11500cf 100644 --- a/services/web/public/coffee/main/project-list/modal-controllers.coffee +++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee @@ -101,11 +101,19 @@ define [ App.controller 'DeleteProjectsModalController', ($scope, $modalInstance, $timeout, projects) -> $scope.projectsToDelete = projects.filter (project) -> project.accessLevel == "owner" $scope.projectsToLeave = projects.filter (project) -> project.accessLevel != "owner" + $scope.projectsToArchive = projects.filter (project) -> + project.accessLevel == "owner" and !project.archived if $scope.projectsToLeave.length > 0 and $scope.projectsToDelete.length > 0 - $scope.action = "delete-and-leave" + if $scope.projectsToArchive.length > 0 and window.ExposedSettings.isOverleaf + $scope.action = "archive-and-leave" + else + $scope.action = "delete-and-leave" else if $scope.projectsToLeave.length == 0 and $scope.projectsToDelete.length > 0 - $scope.action = "delete" + if $scope.projectsToArchive.length > 0 and window.ExposedSettings.isOverleaf + $scope.action = "archive" + else + $scope.action = "delete" else $scope.action = "leave" diff --git a/services/web/public/img/review-icon-sprite-ol.png b/services/web/public/img/review-icon-sprite-ol.png new file mode 100644 index 0000000000..1e5c7192e4 Binary files /dev/null and b/services/web/public/img/review-icon-sprite-ol.png differ diff --git a/services/web/public/img/review-icon-sprite-ol@2x.png b/services/web/public/img/review-icon-sprite-ol@2x.png new file mode 100644 index 0000000000..87aec7c163 Binary files /dev/null and b/services/web/public/img/review-icon-sprite-ol@2x.png differ diff --git a/services/web/public/stylesheets/_style_includes.less b/services/web/public/stylesheets/_style_includes.less index 968304ba3d..88a4ad59bc 100644 --- a/services/web/public/stylesheets/_style_includes.less +++ b/services/web/public/stylesheets/_style_includes.less @@ -43,6 +43,8 @@ @import "components/hover.less"; @import "components/ui-select.less"; @import "components/input-suggestions.less"; +@import "components/nvd3.less"; +@import "components/nvd3_override.less"; // Components w/ JavaScript @import "components/modals.less"; diff --git a/services/web/public/stylesheets/app/account-settings.less b/services/web/public/stylesheets/app/account-settings.less index f8663f7b5f..3b6d321925 100644 --- a/services/web/public/stylesheets/app/account-settings.less +++ b/services/web/public/stylesheets/app/account-settings.less @@ -24,18 +24,36 @@ width: 40%; } .affiliations-table-inline-actions { - width: 20%; + text-align: right; } + .affiliations-table-inline-action { + text-transform: capitalize; + } + .affiliations-table-inline-action-disabled-wrapper { + display: inline-block; + } .affiliations-table-highlighted-row { background-color: tint(@content-alt-bg-color, 6%); } - + .affiliations-table-error-row { + background-color: @alert-danger-bg; + color: @alert-danger-text; + .btn { + margin-top: @table-cell-padding; + .button-variant(@btn-danger-color; darken(@btn-danger-bg, 8%); @btn-danger-border); + } + } .affiliations-form-group { margin-top: @table-cell-padding; &:first-child { margin-top: 0; } } + .affiliation-change-container, + .affiliation-change-actions { + margin-top: @table-cell-padding; + } + .affiliations-table-label { padding-top: 4px; } diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index 597db09f74..7e0e63113b 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -93,6 +93,8 @@ height: @editor-toolbar-height; background-color: @editor-toolbar-bg; overflow: hidden; + position: relative; + z-index: 10; // Prevent track changes showing over toolbar } .loading-screen { diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index 9089ed1ae9..cfefbb462c 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -15,16 +15,30 @@ .history-toolbar when (@is-overleaf = false) { border-bottom: @toolbar-border-bottom; } - .history-toolbar-time { - font-weight: bold; + .history-toolbar-selected-version { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - .history-toolbar-btn { - .btn; - .btn-info; - .btn-xs; - padding-left: @padding-small-horizontal; - padding-right: @padding-small-horizontal; - margin-left: (@line-height-computed / 2); + .history-toolbar-time, + .history-toolbar-selected-label { + font-weight: bold; + } + .history-toolbar-actions { + flex-grow: 1; + } + .history-toolbar-btn { + .btn; + .btn-info; + .btn-xs; + padding-left: @padding-small-horizontal; + padding-right: @padding-small-horizontal; + margin-left: (@line-height-computed / 2); + } + .history-toolbar-entries-list { + flex: 0 0 @changesListWidth; + padding: 0 10px; + border-left: 1px solid @editor-border-color; } .history-entries { @@ -48,11 +62,76 @@ padding: 5px 10px; cursor: pointer; - .history-entry-selected & { + .history-entry-selected &, + .history-entry-label-selected & { background-color: @history-entry-selected-bg; color: #FFF; } } + .history-label { + display: inline-block; + color: @history-entry-label-color; + font-size: @font-size-small; + margin-bottom: 3px; + margin-right: 10px; + white-space: nowrap; + .history-entry-selected &, + .history-entry-label-selected & { + color: @history-entry-selected-label-color; + } + } + .history-label-comment, + .history-label-delete-btn { + padding: 0 @padding-xs-horizontal 1px @padding-xs-horizontal; + border: 0; + background-color: @history-entry-label-bg-color; + .history-entry-selected &, + .history-entry-label-selected & { + background-color: @history-entry-selected-label-bg-color; + } + } + .history-label-comment { + display: block; + float: left; + border-radius: 9999px; + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + .history-label-own & { + padding-right: (@padding-xs-horizontal / 2); + border-radius: 9999px 0 0 9999px; + } + } + .history-label-delete-btn { + padding-left: (@padding-xs-horizontal / 2); + padding-right: @padding-xs-horizontal; + border-radius: 0 9999px 9999px 0; + &:hover { + background-color: darken(@history-entry-label-bg-color, 8%); + .history-entry-selected &, + .history-entry-label-selected & { + background-color: darken(@history-entry-selected-label-bg-color, 8%); + } + } + } + + .history-label-tooltip { + white-space: normal; + padding: (@line-height-computed / 4); + text-align: left; + } + .history-label-tooltip-title, + .history-label-tooltip-owner, + .history-label-tooltip-datetime { + margin: 0 0 (@line-height-computed / 4) 0; + } + .history-label-tooltip-title { + font-weight: bold; + } + .history-label-tooltip-datetime { + margin-bottom: 0; + } + .history-entry-changes { .list-unstyled; margin-bottom: 3px; @@ -68,7 +147,8 @@ color: @history-highlight-color; font-weight: bold; word-break: break-all; - .history-entry-selected & { + .history-entry-selected &, + .history-entry-label-selected & { color: #FFF; } } @@ -93,6 +173,19 @@ } } +.history-labels-list { + .history-entries; + overflow-y: auto; +} + .history-entry-label { + .history-entry-details; + padding: 7px 10px; + &.history-entry-label-selected { + background-color: @history-entry-selected-bg; + color: #FFF; + } + } + .history-file-tree-inner { .full-size; overflow-y: auto; @@ -169,330 +262,3 @@ color: @brand-primary; } } -// @changesListWidth: 250px; -// @changesListPadding: @line-height-computed / 2; - -// @selector-padding-vertical: 10px; -// @selector-padding-horizontal: @line-height-computed / 2; -// @day-header-height: 24px; - -// @range-bar-color: @link-color; -// @range-bar-selected-offset: 14px; - -// #history { -// .upgrade-prompt { -// position: absolute; -// top: 0; -// bottom: 0; -// left: 0; -// right: 0; -// z-index: 100; -// background-color: rgba(128,128,128,0.4); -// .message { -// margin: auto; -// margin-top: 100px; -// padding: (@line-height-computed / 2) @line-height-computed; -// width: 400px; -// background-color: white; -// border-radius: 8px; -// } -// .message-wider { -// width: 650px; -// margin-top: 60px; -// padding: 0; -// } - -// .message-header { -// .modal-header; -// } - -// .message-body { -// .modal-body; -// } -// } - -// .diff-panel { -// .full-size; -// margin-right: @changesListWidth; -// } - -// .diff { -// .full-size; -// .toolbar { -// padding: 3px; -// .name { -// float: left; -// padding: 3px @line-height-computed / 4; -// display: inline-block; -// } -// } -// .diff-editor { -// .full-size; -// top: 40px; -// } -// .hide-ace-cursor { -// .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line { -// display: none; -// } -// } -// .diff-deleted { -// padding: @line-height-computed; -// } -// .deleted-warning { -// background-color: @brand-danger; -// color: white; -// padding: @line-height-computed / 2; -// margin-right: @line-height-computed / 4; -// } -// &-binary { -// .alert { -// margin: @line-height-computed / 2; -// } -// } -// } - -// aside.change-list { -// border-left: 1px solid @editor-border-color; -// height: 100%; -// width: @changesListWidth; -// position: absolute; -// right: 0; - -// .loading { -// text-align: center; -// font-family: @font-family-serif; -// } - -// ul { -// li.change { -// position: relative; -// user-select: none; -// -ms-user-select: none; -// -moz-user-select: none; -// -webkit-user-select: none; - -// .day { -// background-color: #fafafa; -// border-bottom: 1px solid @editor-border-color; -// padding: 4px; -// font-weight: bold; -// text-align: center; -// height: @day-header-height; -// font-size: 14px; -// line-height: 1; -// } -// .selectors { -// input { -// margin: 0; -// } -// position: absolute; -// left: @selector-padding-horizontal; -// top: 0; -// bottom: 0; -// width: 24px; -// .selector-from { -// position: absolute; -// bottom: @selector-padding-vertical; -// left: 0; -// opacity: 0.8; -// } -// .selector-to { -// position: absolute; -// top: @selector-padding-vertical; -// left: 0; -// opacity: 0.8; -// } -// .range { -// position: absolute; -// left: 5px; -// width: 4px; -// top: 0; -// bottom: 0; -// } -// } -// .description { -// padding: (@line-height-computed / 4); -// padding-left: 38px; -// min-height: 38px; -// border-bottom: 1px solid @editor-border-color; -// cursor: pointer; -// &:hover { -// background-color: @gray-lightest; -// } -// } -// .users { -// .user { -// font-size: 0.8rem; -// color: @gray; -// text-transform: capitalize; -// position: relative; -// padding-left: 16px; -// .color-square { -// height: 12px; -// width: 12px; -// border-radius: 3px; -// position: absolute; -// left: 0; -// bottom: 3px; -// } -// .name { -// width: 94%; -// white-space: nowrap; -// overflow: hidden; -// text-overflow: ellipsis; -// } -// } -// } -// .time { -// float: right; -// color: @gray; -// display: inline-block; -// padding-right: (@line-height-computed / 2); -// font-size: 0.8rem; -// line-height: @line-height-computed; -// } -// .doc { -// font-size: 0.9rem; -// font-weight: bold; -// } -// .action { -// color: @gray; -// text-transform: uppercase; -// font-size: 0.7em; -// margin-bottom: -2px; -// margin-top: 2px; -// &-edited { -// margin-top: 0; -// } -// } -// } -// li.loading-changes, li.empty-message { -// padding: 6px; -// cursor: default; -// &:hover { -// background-color: inherit; -// } -// } -// li.selected { -// border-left: 4px solid @range-bar-color; -// .day { -// padding-left: 0; -// } -// .description { -// padding-left: 34px; -// } -// .selectors { -// left: @selector-padding-horizontal - 4px; -// .range { -// background-color: @range-bar-color; -// } -// } -// } -// li.selected-to { -// .selectors { -// .range { -// top: @range-bar-selected-offset; -// } -// .selector-to { -// opacity: 1; -// } -// } -// } -// li.selected-from { -// .selectors { -// .range { -// bottom: @range-bar-selected-offset; -// } -// .selector-from { -// opacity: 1; -// } -// } -// } -// li.first-in-day { -// .selectors { -// .selector-to { -// top: @day-header-height + @selector-padding-vertical; -// } -// } -// } -// li.first-in-day.selected-to { -// .selectors { -// .range { -// top: @day-header-height + @range-bar-selected-offset; -// } -// } -// } -// } -// ul.hover-state { -// li { -// .selectors { -// .range { -// background-color: transparent; -// top: 0; -// bottom: 0; -// } -// } -// } -// li.hover-selected { -// .selectors { -// .range { -// top: 0; -// background-color: @gray-light; -// } -// } -// } -// li.hover-selected-to { -// .selectors { -// .range { -// top: @range-bar-selected-offset; -// } -// .selector-to { -// opacity: 1; -// } -// } -// } -// li.hover-selected-from { -// .selectors { -// .range { -// bottom: @range-bar-selected-offset; -// } -// .selector-from { -// opacity: 1; -// } -// } -// } -// li.first-in-day.hover-selected-to { -// .selectors { -// .range { -// top: @day-header-height + @range-bar-selected-offset; -// } -// } -// } -// } -// } -// } - -// .diff-deleted { -// padding-top: 15px; -// } - -// .editor-dark { -// #history { -// aside.change-list { -// border-color: @editor-dark-toolbar-border-color; - -// ul li.change { -// .day { -// background-color: darken(@editor-dark-background-color, 10%); -// border-bottom: 1px solid @editor-dark-toolbar-border-color; -// } -// .description { -// border-bottom: 1px solid @editor-dark-toolbar-border-color; -// &:hover { -// background-color: black; -// } -// } -// } -// } -// } -// } diff --git a/services/web/public/stylesheets/app/editor/publish-modal.less b/services/web/public/stylesheets/app/editor/publish-modal.less index d196d3ccf0..3f049fc75b 100644 --- a/services/web/public/stylesheets/app/editor/publish-modal.less +++ b/services/web/public/stylesheets/app/editor/publish-modal.less @@ -23,6 +23,9 @@ .wl-icon:before{ font-size: 14px; } + .btn-wrapping{ + white-space: normal; + } .button-as-link{ color: green; text-transform: none; diff --git a/services/web/public/stylesheets/app/editor/review-panel.less b/services/web/public/stylesheets/app/editor/review-panel.less index 1ed723c3b4..a7e7d3fdbd 100644 --- a/services/web/public/stylesheets/app/editor/review-panel.less +++ b/services/web/public/stylesheets/app/editor/review-panel.less @@ -139,6 +139,10 @@ padding: 0 5px; } + .rp-unsupported & { + display: none; + } + position: relative; border-bottom: 1px solid @rp-border-grey; background-color: @rp-bg-dim-blue; @@ -217,6 +221,10 @@ .rp-size-mini & { display: block; } + + .rp-unsupported & { + display: none; + } .rp-state-current-file & { position: absolute; @@ -714,6 +722,10 @@ display: flex; } + .rp-unsupported & { + display: none; + } + .rp-state-current-file & { position: absolute; bottom: 0; @@ -834,6 +846,31 @@ } } +.rp-unsupported-msg-wrapper { + display: none; + .rp-size-expanded.rp-unsupported & { + display: block; + } + + height: 100%; + + .rp-unsupported-msg { + display: flex; + width: @review-panel-width - 40px; + height: 100%; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0 auto; + text-align: center; + + .rp-unsupported-msg-title { + font-size: 1.3em; + margin-top: 13px; + } + } +} + .ace-editor-wrapper { .track-changes-marker-callout { border-radius: 0; @@ -885,7 +922,7 @@ } } -.review-icon { +.review-icon when (@is-overleaf = false) { display: inline-block; background: url('/img/review-icon-sprite.png') top/30px no-repeat; width: 30px; @@ -908,10 +945,17 @@ } } -.review-icon when (@is-overleaf) { - background-position-y: -60px; - .toolbar .btn-full-height:hover & { - background-position-y: -60px; +.review-icon when (@is-overleaf = true) { + display: inline-block; + background: url('/img/review-icon-sprite-ol.png') top/30px no-repeat; + width: 30px; + + &::before { + content: '\00a0'; // Non-breakable space. A non-breakable character here makes this icon work like font-awesome. + } + + @media (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { + background-image: url('/img/review-icon-sprite-ol@2x.png'); } } @@ -1003,7 +1047,8 @@ .rp-size-mini & { right: @review-off-width; } - .rp-size-expanded & { + .rp-size-expanded &, + .rp-unsupported & { display: none; } } @@ -1053,6 +1098,10 @@ display: block; } + .rp-unsupported & { + display: none; + } + .rp-size-expanded & { &::after { content: "\f105"; diff --git a/services/web/public/stylesheets/app/editor/rich-text.less b/services/web/public/stylesheets/app/editor/rich-text.less index 825c348fbf..f96e707dcc 100644 --- a/services/web/public/stylesheets/app/editor/rich-text.less +++ b/services/web/public/stylesheets/app/editor/rich-text.less @@ -1,12 +1,21 @@ -@rt-font-family: 'Source Sans Pro', 'Helvetica', 'Arial', sans-serif; -// @rt-font-family-serif: 'Palatino Linotype', 'Book Antiqua', Palatino, serif; +@rt-font-family-serif: 'Palatino Linotype', 'Book Antiqua', Palatino, serif; +@rt-line-padding: 8%; .rich-text .CodeMirror { - font-family: @rt-font-family; + font-family: @rt-font-family-serif; font-size: 1.15em; pre { - font-family: @rt-font-family; + font-family: @rt-font-family-serif; + } + + .CodeMirror-line { + // Add horizontal padding, to emulate a manuscript more closely + padding: 0 @rt-line-padding; + } + + .CodeMirror-linenumber { + font-size: 0.8em; } // TODO: Change prefix away from wl- ? @@ -32,44 +41,48 @@ /****************************************************************************/ + // wl-indent-X is used to add extra left padding to nested itemize/enumerate + // environments, so that the inner list appears more indented than the outer .wl-indent-0 { - padding-left: 2.5em !important; + padding-left: calc(~"2.5em + @{rt-line-padding}") !important; } .wl-indent-1 { - padding-left: 3.5em !important; + padding-left: calc(~"3.5em + @{rt-line-padding}") !important; } .wl-indent-2 { - padding-left: 4.5em !important; + padding-left: calc(~"4.5em + @{rt-line-padding}") !important; } .wl-indent-3 { - padding-left: 5.5em !important; + padding-left: calc(~"5.5em + @{rt-line-padding}") !important; } .wl-indent-4 { - padding-left: 6.5em !important; + padding-left: calc(~"6.5em + @{rt-line-padding}") !important; } + // wl-indent-env-X is used to add extra left padding to empty nested itemize/ + // enumerate environments .wl-indent-env-0 { - padding-left: 4px !important; + padding-left: calc(~"4px + @{rt-line-padding}") !important; } .wl-indent-env-1 { - padding-left: 1.5em !important; + padding-left: calc(~"1.5em + @{rt-line-padding}") !important; } .wl-indent-env-2 { - padding-left: 2.5em !important; + padding-left: calc(~"2.5em + @{rt-line-padding}") !important; } .wl-indent-env-3 { - padding-left: 3.5em !important; + padding-left: calc(~"3.5em + @{rt-line-padding}") !important; } .wl-indent-env-4 { - padding-left: 4.5em !important; + padding-left: calc(~"4.5em + @{rt-line-padding}") !important; } .wl-enumerate-item-open { @@ -108,7 +121,7 @@ .wl-figure-wrap { padding: 10px 0; background-color: #f5f5f5; - box-shadow: 2px 2px 2px #DFDFDF; + box-shadow: 1.3px 2px 2px #DFDFDF; width: 96%; margin: 0 auto; text-align: center; diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index a65cc9094b..dcc0344c73 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -194,56 +194,57 @@ } .toggle-switch { - position: relative; + position: relative; height: 100%; width: 100%; - background-color: @toggle-switch-bg; - border-radius: @btn-border-radius-base; + background-color: @toggle-switch-bg; + border-radius: @btn-border-radius-base; } .toggle-switch-label { position: relative; display: block; font-weight: normal; - z-index: 2; - float: left; - width: 50%; - height: 100%; - line-height: 24px; + z-index: 2; + float: left; + width: 50%; + height: 100%; + line-height: 24px; text-align: center; margin-bottom: 0; - cursor: pointer; - user-select: none; - transition: color 0.12s ease-out; + cursor: pointer; + user-select: none; + color: @text-color; + transition: color 0.12s ease-out; } .toggle-switch-input { - position: absolute; - opacity: 0; + position: absolute; + opacity: 0; } .toggle-switch-input:checked + .toggle-switch-label { - color: #fff; - font-weight: bold; + color: #fff; + font-weight: bold; } .toggle-switch-selection { - display: block; - position: absolute; - z-index: 1; - top: 2px; - left: 2px; - right: 2px; - width: calc(~"50% - 2px"); - height: calc(~"100% - 4px"); - background: @toggle-switch-highlight-color; - border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base; - transition: transform 0.12s ease-out, border-radius 0.12s ease-out; + display: block; + position: absolute; + z-index: 1; + top: 2px; + left: 2px; + right: 2px; + width: calc(~"50% - 2px"); + height: calc(~"100% - 4px"); + background: @toggle-switch-highlight-color; + border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base; + transition: transform 0.12s ease-out, border-radius 0.12s ease-out; } .toggle-switch-input:checked:nth-child(4) ~ .toggle-switch-selection { - transform: translate(100%); - border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0; + transform: translate(100%); + border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0; } /************************************** diff --git a/services/web/public/stylesheets/app/metrics.less b/services/web/public/stylesheets/app/metrics.less index 23d121088d..244021bcad 100644 --- a/services/web/public/stylesheets/app/metrics.less +++ b/services/web/public/stylesheets/app/metrics.less @@ -87,6 +87,11 @@ margin-top: 0; margin-bottom: 0; } + + .metric-tooltip { + top: -1em; + font-size: .5em; + } } // END: Metrics header diff --git a/services/web/public/stylesheets/app/portals.less b/services/web/public/stylesheets/app/portals.less new file mode 100644 index 0000000000..ae6fcdf230 --- /dev/null +++ b/services/web/public/stylesheets/app/portals.less @@ -0,0 +1,161 @@ +.content-portal { + padding-top: @navbar-height!important; + + /* + Begin Header + */ + .banner-image { + background-size: cover; + background-position: 50% 50%; + background-repeat: no-repeat; + height: 375px; + } + + .image-fill { + display: inline-block; + height: 100%; + vertical-align: middle; + } + + .institution-logo { + left: 50%; + margin-left: -100px; + padding: 0; + position: absolute; + div { + background-color: @white; + box-shadow: 1px 11px 22px -9px @black-alpha-strong; + display: inline-block; + height: 125px; + overflow: hidden; + position: absolute; + text-align: center; + top: -110px; + white-space: nowrap; + width: @btn-portal-width; + } + img { + max-height: 75px; + max-width: 150px; + vertical-align: middle; + } + } + + .portal-name { + background-color: @ol-blue-gray-0; + padding-bottom: @line-height-computed; //- center header when no tabs + padding-top: @padding-md; + text-align: center; + width: 100%; + } + // End Header + + /* + Begin Layout + */ + .button-pull, + .content-pull { + float: left; + } + + .button-pull { + text-align: right; + > a.btn { + white-space: normal; + width: @btn-portal-width; + text-align: center; + } + } + .content-pull { + padding-right: @padding-sm; + width: calc(~"100% - "@btn-portal-width); + } + // End Layout + + /* + Begin Card + */ + .card { + margin-bottom: @margin-md; + } + // End Card + + /* + Begin Actions + */ + .portal-actions { + i { + margin-bottom: @margin-sm; + } + } + // End Actions + + /* + Begin Print + */ + .print { + .hidden-print { + display: none; + } + } + // End Print + + /* + Begin Tabs + */ + .nav-tabs { + // Overrides for nav.less + background-color: @ol-blue-gray-0; + border: 0!important; + margin-bottom: @margin-md; + margin-top: -@line-height-computed; //- adjusted for portal-name + padding: @padding-lg 0 @padding-md; + text-align: center; + + a { + color: @link-color; + &:hover { + background-color: transparent!important; + border: 0!important; + color: @link-hover-color!important; + } + } + + li { + display: inline-block; + float: none; + a { + border: 0; + } + } + + li.active > a { + background-color: transparent!important; + border: 0; + border-bottom: 1px solid @accent-color-secondary!important; + color: @accent-color-secondary; + &:hover { + color: @accent-color-secondary!important; + } + } + } + + .tab-content:extend(.container) { + background-color: transparent!important; + border: none!important; + } + // End Tabs + + @media (max-width: @screen-size-sm-max) { + .content-pull { + padding: 0; + width: auto; + } + + .button-pull { + > a.btn { + width: auto; + } + } + } +} diff --git a/services/web/public/stylesheets/app/wiki.less b/services/web/public/stylesheets/app/wiki.less index 5f7462d02c..29ccf505e6 100644 --- a/services/web/public/stylesheets/app/wiki.less +++ b/services/web/public/stylesheets/app/wiki.less @@ -13,6 +13,10 @@ font-size: 18px; } } + + img { + max-width: 100%; + } .page-header { a { diff --git a/services/web/public/stylesheets/components/daterange-picker.less b/services/web/public/stylesheets/components/daterange-picker.less index 2d15d2a36d..f0dcc01d27 100644 --- a/services/web/public/stylesheets/components/daterange-picker.less +++ b/services/web/public/stylesheets/components/daterange-picker.less @@ -34,7 +34,7 @@ @daterangepicker-in-range-bg-color: #ebf4f8; @daterangepicker-active-color: #fff; -@daterangepicker-active-bg-color: #a93529; +@daterangepicker-active-bg-color: #4F9C45; @daterangepicker-active-border-color: transparent; @daterangepicker-unselected-color: #999; @@ -393,13 +393,14 @@ .daterangepicker_input { position: relative; + padding-left: 0; i { position: absolute; // NOTE: These appear to be eyeballed to me... left: 8px; - top: 8px; + top: 10px; } } &.rtl { @@ -476,6 +477,19 @@ /* Larger Screen Styling */ @media (min-width: 564px) { .daterangepicker { + .glyphicon { + font-family: FontAwesome; + } + .glyphicon-chevron-left:before{ + content: "\f053"; + } + .glyphicon-chevron-right:before{ + content: "\f054"; + } + .glyphicon-calendar:before{ + content: "\f073"; + } + width: auto; .ranges { diff --git a/services/web/public/stylesheets/components/embed-responsive.less b/services/web/public/stylesheets/components/embed-responsive.less new file mode 100644 index 0000000000..997f100449 --- /dev/null +++ b/services/web/public/stylesheets/components/embed-responsive.less @@ -0,0 +1,26 @@ +.embed-responsive { + display: block; + height: 0; + overflow: hidden; + padding: 0; + position: relative; +} +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} +.embed-responsive-16by9 { + padding-bottom: 56.25% !important; +} +.embed-responsive-4by3 { + padding-bottom: 75% !important; +} \ No newline at end of file diff --git a/services/web/public/stylesheets/components/icons.less b/services/web/public/stylesheets/components/icons.less new file mode 100644 index 0000000000..fa808ad5ae --- /dev/null +++ b/services/web/public/stylesheets/components/icons.less @@ -0,0 +1,9 @@ +// Colors +.icon-accent { + color: @accent-color-secondary; +} + +// Sizes +.icon-lg { + font-size: @font-size-h1; +} \ No newline at end of file diff --git a/services/web/public/stylesheets/components/input-suggestions.less b/services/web/public/stylesheets/components/input-suggestions.less index bf2fd4a253..2424ad5cdc 100644 --- a/services/web/public/stylesheets/components/input-suggestions.less +++ b/services/web/public/stylesheets/components/input-suggestions.less @@ -10,7 +10,7 @@ .input-suggestions-shadow { background-color: @input-bg; - padding-top: 4px; + padding-top: @input-suggestion-v-offset; } .input-suggestions-shadow-existing { color: transparent; diff --git a/services/web/public/stylesheets/components/nvd3.less b/services/web/public/stylesheets/components/nvd3.less new file mode 100755 index 0000000000..5ddcdc7a01 --- /dev/null +++ b/services/web/public/stylesheets/components/nvd3.less @@ -0,0 +1,674 @@ +/* nvd3 version 1.8.4 (https://github.com/novus/nvd3) 2016-07-03 */ +.nvd3 .nv-axis { + pointer-events:none; + opacity: 1; +} + +.nvd3 .nv-axis path { + fill: none; + stroke: #000; + stroke-opacity: .75; + shape-rendering: crispEdges; +} + +.nvd3 .nv-axis path.domain { + stroke-opacity: .75; +} + +.nvd3 .nv-axis.nv-x path.domain { + stroke-opacity: 0; +} + +.nvd3 .nv-axis line { + fill: none; + stroke: #e5e5e5; + shape-rendering: crispEdges; +} + +.nvd3 .nv-axis .zero line, + /*this selector may not be necessary*/ .nvd3 .nv-axis line.zero { + stroke-opacity: .75; +} + +.nvd3 .nv-axis .nv-axisMaxMin text { + font-weight: bold; +} + +.nvd3 .x .nv-axis .nv-axisMaxMin text, +.nvd3 .x2 .nv-axis .nv-axisMaxMin text, +.nvd3 .x3 .nv-axis .nv-axisMaxMin text { + text-anchor: middle +} + +.nvd3 .nv-axis.nv-disabled { + opacity: 0; +} + +.nvd3 .nv-bars rect { + fill-opacity: .75; + + transition: fill-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-bars rect.hover { + fill-opacity: 1; +} + +.nvd3 .nv-bars .hover rect { + fill: lightblue; +} + +.nvd3 .nv-bars text { + fill: rgba(0,0,0,0); +} + +.nvd3 .nv-bars .hover text { + fill: rgba(0,0,0,1); +} + +.nvd3 .nv-multibar .nv-groups rect, +.nvd3 .nv-multibarHorizontal .nv-groups rect, +.nvd3 .nv-discretebar .nv-groups rect { + stroke-opacity: 0; + + transition: fill-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear; +} + +.nvd3 .nv-multibar .nv-groups rect:hover, +.nvd3 .nv-multibarHorizontal .nv-groups rect:hover, +.nvd3 .nv-candlestickBar .nv-ticks rect:hover, +.nvd3 .nv-discretebar .nv-groups rect:hover { + fill-opacity: 1; +} + +.nvd3 .nv-discretebar .nv-groups text, +.nvd3 .nv-multibarHorizontal .nv-groups text { + font-weight: bold; + fill: rgba(0,0,0,1); + stroke: rgba(0,0,0,0); +} + +/* boxplot CSS */ +.nvd3 .nv-boxplot circle { + fill-opacity: 0.5; +} + +.nvd3 .nv-boxplot circle:hover { + fill-opacity: 1; +} + +.nvd3 .nv-boxplot rect:hover { + fill-opacity: 1; +} + +.nvd3 line.nv-boxplot-median { + stroke: black; +} + +.nv-boxplot-tick:hover { + stroke-width: 2.5px; +} +/* bullet */ +.nvd3.nv-bullet { font: 10px sans-serif; } +.nvd3.nv-bullet .nv-measure { fill-opacity: .8; } +.nvd3.nv-bullet .nv-measure:hover { fill-opacity: 1; } +.nvd3.nv-bullet .nv-marker { stroke: #000; stroke-width: 2px; } +.nvd3.nv-bullet .nv-markerTriangle { stroke: #000; fill: #fff; stroke-width: 1.5px; } +.nvd3.nv-bullet .nv-markerLine { stroke: #000; stroke-width: 1.5px; } +.nvd3.nv-bullet .nv-tick line { stroke: #666; stroke-width: .5px; } +.nvd3.nv-bullet .nv-range.nv-s0 { fill: #eee; } +.nvd3.nv-bullet .nv-range.nv-s1 { fill: #ddd; } +.nvd3.nv-bullet .nv-range.nv-s2 { fill: #ccc; } +.nvd3.nv-bullet .nv-title { font-size: 14px; font-weight: bold; } +.nvd3.nv-bullet .nv-subtitle { fill: #999; } + + +.nvd3.nv-bullet .nv-range { + fill: #bababa; + fill-opacity: .4; +} +.nvd3.nv-bullet .nv-range:hover { + fill-opacity: .7; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect { + stroke: #d62728; + fill: #d62728; +} + +.with-transitions .nv-candlestickBar .nv-ticks .nv-tick { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + +} + +.nvd3.nv-candlestickBar .nv-ticks line { + stroke: #333; +} + + +.nv-force-node { + stroke: #fff; + stroke-width: 1.5px; +} +.nv-force-link { + stroke: #999; + stroke-opacity: .6; +} +.nv-force-node text { + stroke-width: 0px +} + +.nvd3 .nv-legend .nv-disabled rect { + /*fill-opacity: 0;*/ +} + +.nvd3 .nv-check-box .nv-box { + fill-opacity:0; + stroke-width:2; +} + +.nvd3 .nv-check-box .nv-check { + fill-opacity:0; + stroke-width:4; +} + +.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check { + fill-opacity:0; + stroke-opacity:0; +} + +.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check { + opacity: 0; +} + +/* line plus bar */ +.nvd3.nv-linePlusBar .nv-bar rect { + fill-opacity: .75; +} + +.nvd3.nv-linePlusBar .nv-bar rect:hover { + fill-opacity: 1; +} +.nvd3 .nv-groups path.nv-line { + fill: none; +} + +.nvd3 .nv-groups path.nv-area { + stroke: none; +} + +.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point { + fill-opacity: .5 !important; + stroke-opacity: .5 !important; +} + + +.with-transitions .nvd3 .nv-groups .nv-point { + transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear; + +} + +.nvd3.nv-scatter .nv-groups .nv-point.hover, +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 7px; + fill-opacity: .95 !important; + stroke-opacity: .95 !important; +} + + +.nvd3 .nv-point-paths path { + stroke: #aaa; + stroke-opacity: 0; + fill: #eee; + fill-opacity: 0; +} + + + +.nvd3 .nv-indexLine { + cursor: ew-resize; +} + +/******************** + * SVG CSS + */ + +/******************** + Default CSS for an svg element nvd3 used +*/ +svg.nvd3-svg { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + user-select: none; + display: block; + width:100%; + height:100%; +} + +/******************** + Box shadow and border radius styling +*/ +.nvtooltip.with-3d-shadow, .with-3d-shadow .nvtooltip { + -moz-box-shadow: 0 5px 10px rgba(0,0,0,.2); + -webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2); + box-shadow: 0 5px 10px rgba(0,0,0,.2); + + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} + + +.nvd3 text { + font: normal 12px Arial; +} + +.nvd3 .title { + font: bold 14px Arial; +} + +.nvd3 .nv-background { + fill: white; + fill-opacity: 0; +} + +.nvd3.nv-noData { + font-size: 18px; + font-weight: bold; +} + + +/********** +* Brush +*/ + +.nv-brush .extent { + fill-opacity: .125; + shape-rendering: crispEdges; +} + +.nv-brush .resize path { + fill: #eee; + stroke: #666; +} + + +/********** +* Legend +*/ + +.nvd3 .nv-legend .nv-series { + cursor: pointer; +} + +.nvd3 .nv-legend .nv-disabled circle { + fill-opacity: 0; +} + +/* focus */ +.nvd3 .nv-brush .extent { + fill-opacity: 0 !important; +} + +.nvd3 .nv-brushBackground rect { + stroke: #000; + stroke-width: .4; + fill: #fff; + fill-opacity: .7; +} + +/********** +* Print +*/ + +@media print { + .nvd3 text { + stroke-width: 0; + fill-opacity: 1; + } +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick { + stroke-width: 1px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover { + stroke-width: 2px; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive { + stroke: #2ca02c; +} + +.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative { + stroke: #d62728; +} + + +.nvd3 .background path { + fill: none; + stroke: #EEE; + stroke-opacity: .4; + shape-rendering: crispEdges; +} + +.nvd3 .foreground path { + fill: none; + stroke-opacity: .7; +} + +.nvd3 .nv-parallelCoordinates-brush .extent +{ + fill: #fff; + fill-opacity: .6; + stroke: gray; + shape-rendering: crispEdges; +} + +.nvd3 .nv-parallelCoordinates .hover { + fill-opacity: 1; + stroke-width: 3px; +} + + +.nvd3 .missingValuesline line { + fill: none; + stroke: black; + stroke-width: 1; + stroke-opacity: 1; + stroke-dasharray: 5, 5; +} +.nvd3.nv-pie path { + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear; + +} + +.nvd3.nv-pie .nv-pie-title { + font-size: 24px; + fill: rgba(19, 196, 249, 0.59); +} + +.nvd3.nv-pie .nv-slice text { + stroke: #000; + stroke-width: 0; +} + +.nvd3.nv-pie path { + stroke: #fff; + stroke-width: 1px; + stroke-opacity: 1; +} + +.nvd3.nv-pie path { + fill-opacity: .7; +} +.nvd3.nv-pie .hover path { + fill-opacity: 1; +} +.nvd3.nv-pie .nv-label { + pointer-events: none; +} +.nvd3.nv-pie .nv-label rect { + fill-opacity: 0; + stroke-opacity: 0; +} + +/* scatter */ +.nvd3 .nv-groups .nv-point.hover { + stroke-width: 20px; + stroke-opacity: .5; +} + +.nvd3 .nv-scatter .nv-point.hover { + fill-opacity: 1; +} +.nv-noninteractive { + pointer-events: none; +} + +.nv-distx, .nv-disty { + pointer-events: none; +} + +/* sparkline */ +.nvd3.nv-sparkline path { + fill: none; +} + +.nvd3.nv-sparklineplus g.nv-hoverValue { + pointer-events: none; +} + +.nvd3.nv-sparklineplus .nv-hoverValue line { + stroke: #333; + stroke-width: 1.5px; +} + +.nvd3.nv-sparklineplus, +.nvd3.nv-sparklineplus g { + pointer-events: all; +} + +.nvd3 .nv-hoverArea { + fill-opacity: 0; + stroke-opacity: 0; +} + +.nvd3.nv-sparklineplus .nv-xValue, +.nvd3.nv-sparklineplus .nv-yValue { + stroke-width: 0; + font-size: .9em; + font-weight: normal; +} + +.nvd3.nv-sparklineplus .nv-yValue { + stroke: #f66; +} + +.nvd3.nv-sparklineplus .nv-maxValue { + stroke: #2ca02c; + fill: #2ca02c; +} + +.nvd3.nv-sparklineplus .nv-minValue { + stroke: #d62728; + fill: #d62728; +} + +.nvd3.nv-sparklineplus .nv-currentValue { + font-weight: bold; + font-size: 1.1em; +} +/* stacked area */ +.nvd3.nv-stackedarea path.nv-area { + fill-opacity: .7; + stroke-opacity: 0; + transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; + -moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; + -webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear; +} + +.nvd3.nv-stackedarea path.nv-area.hover { + fill-opacity: .9; +} + + +.nvd3.nv-stackedarea .nv-groups .nv-point { + stroke-opacity: 0; + fill-opacity: 0; +} + + +.nvtooltip { + position: absolute; + background-color: rgba(255,255,255,1.0); + color: rgba(0,0,0,1.0); + padding: 1px; + border: 1px solid rgba(0,0,0,.2); + z-index: 10000; + display: block; + + font-family: Arial; + font-size: 13px; + text-align: left; + pointer-events: none; + + white-space: nowrap; + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.nvtooltip { + background: rgba(255,255,255, 0.8); + border: 1px solid rgba(0,0,0,0.5); + border-radius: 4px; +} + +/*Give tooltips that old fade in transition by + putting a "with-transitions" class on the container div. +*/ +.nvtooltip.with-transitions, .with-transitions .nvtooltip { + transition: opacity 50ms linear; + -moz-transition: opacity 50ms linear; + -webkit-transition: opacity 50ms linear; + + transition-delay: 200ms; + -moz-transition-delay: 200ms; + -webkit-transition-delay: 200ms; +} + +.nvtooltip.x-nvtooltip, +.nvtooltip.y-nvtooltip { + padding: 8px; +} + +.nvtooltip h3 { + margin: 0; + padding: 4px 14px; + line-height: 18px; + font-weight: normal; + background-color: rgba(247,247,247,0.75); + color: rgba(0,0,0,1.0); + text-align: center; + + border-bottom: 1px solid #ebebeb; + + -webkit-border-radius: 5px 5px 0 0; + -moz-border-radius: 5px 5px 0 0; + border-radius: 5px 5px 0 0; +} + +.nvtooltip p { + margin: 0; + padding: 5px 14px; + text-align: center; +} + +.nvtooltip span { + display: inline-block; + margin: 2px 0; +} + +.nvtooltip table { + margin: 6px; + border-spacing:0; +} + + +.nvtooltip table td { + padding: 2px 9px 2px 0; + vertical-align: middle; +} + +.nvtooltip table td.key { + font-weight: normal; +} +.nvtooltip table td.key.total { + font-weight: bold; +} +.nvtooltip table td.value { + text-align: right; + font-weight: bold; +} + +.nvtooltip table td.percent { + color: darkgray; +} + +.nvtooltip table tr.highlight td { + padding: 1px 9px 1px 0; + border-bottom-style: solid; + border-bottom-width: 1px; + border-top-style: solid; + border-top-width: 1px; +} + +.nvtooltip table td.legend-color-guide div { + width: 8px; + height: 8px; + vertical-align: middle; +} + +.nvtooltip table td.legend-color-guide div { + width: 12px; + height: 12px; + border: 1px solid #999; +} + +.nvtooltip .footer { + padding: 3px; + text-align: center; +} + +.nvtooltip-pending-removal { + pointer-events: none; + display: none; +} + + +/**** +Interactive Layer +*/ +.nvd3 .nv-interactiveGuideLine { + pointer-events:none; +} +.nvd3 line.nv-guideline { + stroke: #ccc; +} diff --git a/services/web/public/stylesheets/components/nvd3_override.less b/services/web/public/stylesheets/components/nvd3_override.less new file mode 100644 index 0000000000..2a84f27ddc --- /dev/null +++ b/services/web/public/stylesheets/components/nvd3_override.less @@ -0,0 +1,16 @@ +.nvd3 { + .nv-axis { + .tick { + line { + opacity: 0; + } + } + path.domain { + opacity: 0; + } + } +} + +svg.nvd3-iddle { + @extend svg.nvd3-svg; +} diff --git a/services/web/public/stylesheets/components/ui-select.less b/services/web/public/stylesheets/components/ui-select.less index 5513378629..f78cc2f6a3 100644 --- a/services/web/public/stylesheets/components/ui-select.less +++ b/services/web/public/stylesheets/components/ui-select.less @@ -35,12 +35,14 @@ > .btn { border-color: @input-border-focus; box-shadow: inset 0 1px 1px rgba(0,0,0,0.075), 0 0 8px fade(@input-border-focus, 60%); + padding-top: @input-suggestion-v-offset; } } > .btn { color: @input-color; background-color: @input-bg; border: 1px solid @input-border; + padding-top: @input-suggestion-v-offset; &[disabled] { cursor: not-allowed; background-color: @input-bg-disabled; @@ -53,6 +55,7 @@ .ui-select-container[tagging] { .ui-select-toggle { cursor: text; + padding-top: @input-suggestion-v-offset; > i.caret.pull-right { display: none; } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 32c8767aaa..87183d8bf1 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -62,9 +62,12 @@ // //## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start). -@margin-sm: 10px; -@margin-md: 20px; -@margin-lg: 30px; +@margin-xs: 5px; +@margin-sm: 10px; +@margin-md: 20px; +@margin-lg: 30px; +@margin-xl: 40px; +@margin-xxl: 50px; @padding-base-vertical: 5px; @padding-base-horizontal: 16px; @@ -988,12 +991,18 @@ @sys-msg-border : 1px solid @common-border-color; // v2 History -@history-base-font-size : @font-size-small; -@history-base-bg : @gray-lightest; -@history-entry-day-bg : @gray; -@history-entry-selected-bg : @red; -@history-base-color : @gray-light; -@history-highlight-color : @gray; -@history-toolbar-bg-color : @toolbar-alt-bg-color; -@history-toolbar-color : @text-color; +@history-base-font-size : @font-size-small; +@history-base-bg : @gray-lightest; +@history-entry-label-bg-color : @red; +@history-entry-label-color : #FFF; +@history-entry-selected-label-bg-color : #FFF; +@history-entry-selected-label-color : @red; +@history-entry-day-bg : @gray; +@history-entry-selected-bg : @red; +@history-base-color : @gray-light; +@history-highlight-color : @gray; +@history-toolbar-bg-color : @toolbar-alt-bg-color; +@history-toolbar-color : @text-color; +// Input suggestions +@input-suggestion-v-offset : 6px; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 27f1c74008..15242024bb 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -6,6 +6,7 @@ @footer-height: 50px; // Styleguide colors +@ol-blue-gray-0 : #f4f5f8; @ol-blue-gray-1 : #E4E8EE; @ol-blue-gray-2 : #9DA7B7; @ol-blue-gray-3 : #5D6879; @@ -21,6 +22,8 @@ @ol-dark-red : #A6312B; @ol-type-color : @ol-blue-gray-3; +@accent-color-primary: @ol-green; +@accent-color-secondary: @ol-dark-green; // Navbar customization @navbar-title-color : @ol-blue-gray-1; @@ -65,8 +68,14 @@ @btn-info-bg : @ol-blue; @btn-info-border : transparent; +// Padding @padding-xs-horizontal : 8px; +@padding-sm: 10px; +@padding-md: 20px; +@padding-lg: 30px; +@padding-xl: 40px; + // Alerts @alert-padding : 15px; @alert-border-radius : @border-radius-base; @@ -168,6 +177,9 @@ @folders-tag-menu-hover : rgba(0, 0, 0, .1); @folders-tag-menu-active-hover : rgba(0, 0, 0, .1); +// Portal +@btn-portal-width : 200px; + // Project table @structured-list-line-height : 2.5; @structured-list-link-color : @ol-blue; @@ -276,22 +288,37 @@ @log-line-no-color : #FFF; @log-hints-color : @ol-blue-gray-4; +// Portals +@black-alpha-strong : rgba(0,0,0,0.8); + // v2 History -@history-base-font-size : @font-size-small; -@history-base-bg : @ol-blue-gray-1; -@history-entry-day-bg : @ol-blue-gray-2; -@history-entry-selected-bg : @ol-green; -@history-base-color : @ol-blue-gray-2; -@history-highlight-color : @ol-type-color; -@history-toolbar-bg-color : @editor-toolbar-bg; -@history-toolbar-color : #FFF; +@history-base-font-size : @font-size-small; +@history-base-bg : @ol-blue-gray-1; +@history-entry-label-bg-color : @ol-blue; +@history-entry-label-color : #FFF; +@history-entry-selected-label-bg-color: #FFF; +@history-entry-selected-label-color : @ol-blue; +@history-entry-day-bg : @ol-blue-gray-2; +@history-entry-selected-bg : @ol-green; +@history-base-color : @ol-blue-gray-2; +@history-highlight-color : @ol-type-color; +@history-toolbar-bg-color : @editor-toolbar-bg; +@history-toolbar-color : #FFF; + +// Screens +// added -size to not conflict with common_variables +@screen-size-sm-max : 767px; +@screen-size-md-min : 768px; +@screen-size-md-max : 991px; // System messages @sys-msg-background : @ol-blue; @sys-msg-color : #FFF; @sys-msg-border : solid 1px lighten(@ol-blue, 10%); +@input-suggestion-v-offset : 4px; + //== Colors // //## Gray and brand colors for use across Bootstrap. @@ -301,6 +328,7 @@ @gray-light: #a4a4a4; @gray-lighter: #cfcfcf; @gray-lightest: #f0f0f0; +@white: #ffffff; @blue: #405ebf; @blueDark: #040D2D; diff --git a/services/web/public/stylesheets/ol-style.less b/services/web/public/stylesheets/ol-style.less index 03e2bb3ed6..774e70a2ab 100644 --- a/services/web/public/stylesheets/ol-style.less +++ b/services/web/public/stylesheets/ol-style.less @@ -6,8 +6,11 @@ @import "app/ol-style-guide.less"; @import "_style_includes.less"; @import "_ol_style_includes.less"; +@import "components/embed-responsive.less"; +@import "components/icons.less"; // Pages @import "app/about.less"; @import "app/blog-posts.less"; -@import "app/cms-page.less"; \ No newline at end of file +@import "app/cms-page.less"; +@import "app/portals.less"; \ No newline at end of file diff --git a/services/web/scripts/increase_compile_timeouts.js b/services/web/scripts/increase_compile_timeouts.js new file mode 100644 index 0000000000..185c563277 --- /dev/null +++ b/services/web/scripts/increase_compile_timeouts.js @@ -0,0 +1,85 @@ +const mongojs = require('../app/js/infrastructure/mongojs') +const { db } = mongojs +const async = require('async') +const minilist = require('minimist') + +const newTimeout = 240 +const oldTimeoutLimits = {$gt: 60, $lt: 240} + +const updateUser = function (user, callback) { + console.log(`Updating user ${user._id}`) + const update = { + $set: { + 'features.compileTimeout': newTimeout + } + } + db.users.update({ + _id: user._id, + 'features.compileTimeout': oldTimeoutLimits + }, update, callback) +} + +const updateUsers = (users, callback) => + async.eachLimit(users, ASYNC_LIMIT, updateUser, function (error) { + if (error) { + callback(error) + return + } + counter += users.length + console.log(`${counter} users updated`) + if (DO_ALL) { + return loopForUsers(callback) + } else { + console.log('*** run again to continue updating ***') + return callback() + } + }) + +var loopForUsers = callback => + db.users.find( + { 'features.compileTimeout': oldTimeoutLimits }, + { 'features.compileTimeout': 1 } + ).limit(FETCH_LIMIT, function (error, users) { + if (error) { + callback(error) + return + } + if (users.length === 0) { + console.log(`DONE (${counter} users updated)`) + return callback() + } + updateUsers(users, callback) + }) + +var counter = 0 +var run = () => + loopForUsers(function (error) { + if (error) { throw error } + process.exit() + }) + +let FETCH_LIMIT, ASYNC_LIMIT, DO_ALL +var setup = function () { + let args = minilist(process.argv.slice(2)) + // --fetch N get N users each time + FETCH_LIMIT = (args.fetch) ? args.fetch : 100 + // --async M run M updates in parallel + ASYNC_LIMIT = (args.async) ? args.async : 10 + // --all means run to completion + if (args.all) { + if (args.fetch) { + console.error('error: do not use --fetch with --all') + process.exit(1) + } else { + DO_ALL = true + // if we are updating for all users then ignore the fetch limit. + FETCH_LIMIT = 0 + // A limit() value of 0 (i.e. .limit(0)) is equivalent to setting + // no limit. + // https://docs.mongodb.com/manual/reference/method/cursor.limit + } + } +} + +setup() +run() diff --git a/services/web/scripts/upgrade_institution_users.js b/services/web/scripts/upgrade_institution_users.js new file mode 100644 index 0000000000..8cf098ca2b --- /dev/null +++ b/services/web/scripts/upgrade_institution_users.js @@ -0,0 +1,16 @@ +const InstitutionsManager = require( + '../app/js/Features/Institutions/InstitutionsManager' +) + +const institutionId = parseInt(process.argv[2]) +if (isNaN(institutionId)) throw new Error('No institution id') +console.log('Upgrading users of institution', institutionId) + +InstitutionsManager.upgradeInstitutionUsers(institutionId, function (error) { + if (error) { + console.log(error) + } else { + console.log('DONE 👌') + } + process.exit() +}) diff --git a/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee index 7c6c106fe2..d2918c1094 100644 --- a/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee +++ b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee @@ -39,6 +39,7 @@ describe "FeatureUpdater.refreshFeatures", -> beforeEach -> Subscription.create { admin_id: @user._id + manager_ids: [@user._id] planCode: 'collaborator' customAccount: true } # returns a promise @@ -83,6 +84,27 @@ describe "FeatureUpdater.refreshFeatures", -> )) done() + describe "when the user has affiliations", -> + beforeEach -> + @institutionPlan = settings.plans.find (plan) -> + plan.planCode == settings.institutionPlanCode + @email = @user.emails[0].email + affiliationData = + email: @email + institution: { licence: 'pro_plus' } + MockV1Api.setAffiliations [affiliationData] + + it "should not set their features if email is not confirmed", (done) -> + syncUserAndGetFeatures @user, (error, features) => + expect(features).to.deep.equal(settings.defaultFeatures) + done() + + it "should set their features if email is confirmed", (done) -> + @user.confirmEmail @email, (error) => + syncUserAndGetFeatures @user, (error, features) => + expect(features).to.deep.equal(@institutionPlan.features) + done() + describe "when the user is due bonus features and has extra features that no longer apply", -> beforeEach -> User.update { @@ -142,6 +164,7 @@ describe "FeatureUpdater.refreshFeatures", -> beforeEach (done) -> Subscription.create { admin_id: @user._id + manager_ids: [@user._id] planCode: 'professional' customAccount: true }, (error) => diff --git a/services/web/test/acceptance/coffee/LabelsTests.coffee b/services/web/test/acceptance/coffee/LabelsTests.coffee new file mode 100644 index 0000000000..8832f2a326 --- /dev/null +++ b/services/web/test/acceptance/coffee/LabelsTests.coffee @@ -0,0 +1,68 @@ +_ = require 'underscore' +{expect} = require 'chai' +{ObjectId} = require 'mongojs' +request = require './helpers/request' + +MockProjectHistoryApi = require './helpers/MockProjectHistoryApi' +User = require './helpers/User' + +describe 'Labels', -> + beforeEach (done) -> + @owner = new User() + @owner.login (error) => + throw error if error? + @owner.createProject 'example-project', {template: 'example'}, (error, @project_id) => + throw error if error? + done() + + afterEach -> + MockProjectHistoryApi.reset() + + it 'getting labels', (done) -> + label_id = new ObjectId().toString() + comment = 'a label comment' + version = 3 + MockProjectHistoryApi.addLabel @project_id, label_id, comment, version + + @owner.request { + method: 'GET' + url: "/project/#{@project_id}/labels" + json: true + }, (error, response, body) => + throw error if error? + expect(response.statusCode).to.equal 200 + expect(body).to.deep.equal [{ label_id, comment, version }] + done() + + it 'creating a label', (done) -> + comment = 'a label comment' + version = 3 + + @owner.request { + method: 'POST' + url: "/project/#{@project_id}/labels" + json: {comment, version} + }, (error, response, body) => + throw error if error? + expect(response.statusCode).to.equal 200 + {label_id} = body + expect( + MockProjectHistoryApi.getLabels(@project_id) + ).to.deep.equal [{label_id, comment, version} ] + done() + + it 'deleting a label', (done) -> + label_id = new ObjectId().toString() + comment = 'a label comment' + version = 3 + MockProjectHistoryApi.addLabel @project_id, label_id, comment, version + + @owner.request { + method: 'DELETE' + url: "/project/#{@project_id}/labels/#{label_id}" + json: true + }, (error, response, body) => + throw error if error? + expect(response.statusCode).to.equal 204 + expect(MockProjectHistoryApi.getLabels(@project_id)).to.deep.equal [] + done() diff --git a/services/web/test/acceptance/coffee/UserEmailsTests.coffee b/services/web/test/acceptance/coffee/UserEmailsTests.coffee index 256c8a690f..3e8ffbd777 100644 --- a/services/web/test/acceptance/coffee/UserEmailsTests.coffee +++ b/services/web/test/acceptance/coffee/UserEmailsTests.coffee @@ -17,7 +17,7 @@ describe "UserEmails", -> token = null async.series [ (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails', json: @@ -45,7 +45,7 @@ describe "UserEmails", -> token = tokens[0].token cb() (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails/confirm', json: @@ -80,7 +80,7 @@ describe "UserEmails", -> (cb) => @user2.login cb (cb) => # Create email for first user - @user.request { + @user.request { method: 'POST', url: '/user/emails', json: {@email} @@ -99,21 +99,21 @@ describe "UserEmails", -> cb() (cb) => # Delete the email from the first user - @user.request { + @user.request { method: 'POST', url: '/user/emails/delete', json: {@email} }, cb (cb) => # Create email for second user - @user2.request { + @user2.request { method: 'POST', url: '/user/emails', json: {@email} }, cb (cb) => # Original confirmation token should no longer work - @user.request { + @user.request { method: 'POST', url: '/user/emails/confirm', json: @@ -158,7 +158,7 @@ describe "UserEmails", -> token = null async.series [ (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails', json: @@ -183,12 +183,12 @@ describe "UserEmails", -> db.tokens.update { token: token }, { - $set: { + $set: { expiresAt: new Date(Date.now() - 1000000) } }, cb (cb) => - @user.request { + @user.request { method: 'POST', url: '/user/emails/confirm', json: @@ -198,3 +198,285 @@ describe "UserEmails", -> expect(response.statusCode).to.equal 404 cb() ], done + + describe 'resending the confirmation', -> + it 'should generate a new token', (done) -> + async.series [ + (cb) => + @user.request { + method: 'POST', + url: '/user/emails', + json: + email: 'reconfirmation-email@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 204 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + # There should only be one confirmation token at the moment + expect(tokens.length).to.equal 1 + expect(tokens[0].data.email).to.equal 'reconfirmation-email@example.com' + expect(tokens[0].data.user_id).to.equal @user._id + cb() + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/resend_confirmation', + json: + email: 'reconfirmation-email@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 200 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + # There should be two tokens now + expect(tokens.length).to.equal 2 + expect(tokens[0].data.email).to.equal 'reconfirmation-email@example.com' + expect(tokens[0].data.user_id).to.equal @user._id + expect(tokens[1].data.email).to.equal 'reconfirmation-email@example.com' + expect(tokens[1].data.user_id).to.equal @user._id + cb() + ], done + + it 'should create a new token if none exists', (done) -> + # This should only be for users that have sign up with their main + # emails before the confirmation system existed + async.series [ + (cb) => + db.tokens.remove { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/resend_confirmation', + json: + email: @user.email + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 200 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + # There should still only be one confirmation token + expect(tokens.length).to.equal 1 + expect(tokens[0].data.email).to.equal @user.email + expect(tokens[0].data.user_id).to.equal @user._id + cb() + ], done + + it "should not allow reconfirmation if the email doesn't match the user", (done) -> + async.series [ + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/resend_confirmation', + json: + email: 'non-matching-email@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 422 + cb() + (cb) => + db.tokens.find { + use: 'email_confirmation', + 'data.user_id': @user._id, + usedAt: { $exists: false } + }, (error, tokens) => + expect(tokens.length).to.equal 0 + cb() + ], done + + describe 'setting a default email', -> + it 'should update confirmed emails for users not in v1', (done) -> + token = null + async.series [ + (cb) => + @user.request { + method: 'POST', + url: '/user/emails', + json: + email: 'new-confirmed-default@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 204 + cb() + (cb) => + # Mark the email as confirmed + db.users.update { + 'emails.email': 'new-confirmed-default@example.com' + }, { + $set: { + 'emails.$.confirmedAt': new Date() + } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/default', + json: + email: 'new-confirmed-default@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 200 + cb() + (cb) => + @user.request { url: '/user/emails', json: true }, (error, response, body) -> + expect(response.statusCode).to.equal 200 + expect(body[0].confirmedAt).to.not.exist + expect(body[0].default).to.equal false + expect(body[1].confirmedAt).to.exist + expect(body[1].default).to.equal true + cb() + ], done + + it 'should not allow changing unconfirmed emails in v1', (done) -> + token = null + async.series [ + (cb) => + db.users.update { + _id: ObjectId(@user._id) + }, { + $set: { + 'overleaf.id': 42 + } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails', + json: + email: 'new-unconfirmed-default@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 204 + cb() + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/default', + json: + email: 'new-unconfirmed-default@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 409 + cb() + (cb) => + @user.request { url: '/user/emails', json: true }, (error, response, body) -> + expect(body[0].default).to.equal true + expect(body[1].default).to.equal false + cb() + ], done + + it 'should update the email in v1 if confirmed', (done) -> + token = null + async.series [ + (cb) => + db.users.update { + _id: ObjectId(@user._id) + }, { + $set: { + 'overleaf.id': 42 + } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails', + json: + email: 'new-confirmed-default-in-v1@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 204 + cb() + (cb) => + # Mark the email as confirmed + db.users.update { + 'emails.email': 'new-confirmed-default-in-v1@example.com' + }, { + $set: { + 'emails.$.confirmedAt': new Date() + } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/default', + json: + email: 'new-confirmed-default-in-v1@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 200 + cb() + ], (error) => + return done(error) if error? + expect( + MockV1Api.updateEmail.calledWith(42, 'new-confirmed-default-in-v1@example.com') + ).to.equal true + done() + + it 'should return an error if the email exists in v1', (done) -> + MockV1Api.existingEmails.push 'exists-in-v1@example.com' + async.series [ + (cb) => + db.users.update { + _id: ObjectId(@user._id) + }, { + $set: { + 'overleaf.id': 42 + } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails', + json: + email: 'exists-in-v1@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 204 + cb() + (cb) => + # Mark the email as confirmed + db.users.update { + 'emails.email': 'exists-in-v1@example.com' + }, { + $set: { + 'emails.$.confirmedAt': new Date() + } + }, cb + (cb) => + @user.request { + method: 'POST', + url: '/user/emails/default', + json: + email: 'exists-in-v1@example.com' + }, (error, response, body) => + return done(error) if error? + expect(response.statusCode).to.equal 409 + expect(body).to.deep.equal { + message: "This email is already registered" + } + cb() + (cb) => + @user.request { url: '/user/emails', json: true }, (error, response, body) -> + expect(body[0].default).to.equal true + expect(body[1].default).to.equal false + cb() + ], done \ No newline at end of file diff --git a/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee b/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee index b7df202d72..f73b6007a4 100644 --- a/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee @@ -1,5 +1,8 @@ -express = require("express") +_ = require 'lodash' +express = require 'express' +bodyParser = require "body-parser" app = express() +{ObjectId} = require 'mongojs' module.exports = MockProjectHistoryApi = docs: {} @@ -8,12 +11,30 @@ module.exports = MockProjectHistoryApi = projectVersions: {} + labels: {} + addOldFile: (project_id, version, pathname, content) -> @oldFiles["#{project_id}:#{version}:#{pathname}"] = content setProjectVersion: (project_id, version) -> @projectVersions[project_id] = version + addLabel: (project_id, label_id, comment, version) -> + @labels[project_id] ?= {} + @labels[project_id][label_id] = {label_id,comment,version} + + deleteLabel: (project_id, label_id) -> + delete @labels[project_id][label_id] + + getLabels: (project_id) -> + return null unless @labels[project_id]? + _.values @labels[project_id] + + reset: () -> + @oldFiles = {} + @projectVersions = {} + @labels = {} + run: () -> app.post "/project", (req, res, next) => res.json project: id: 1 @@ -33,11 +54,34 @@ module.exports = MockProjectHistoryApi = else res.send 404 + app.get "/project/:project_id/labels", (req, res, next) => + {project_id} = req.params + labels = @getLabels project_id + if labels? + res.json labels + else + res.send 404 + + app.post "/project/:project_id/user/:user_id/labels", bodyParser.json(), (req, res, next) => + {project_id} = req.params + {comment, version} = req.body + label_id = new ObjectId().toString() + @addLabel project_id, label_id, comment, version + res.json {label_id, comment, version} + + app.delete "/project/:project_id/user/:user_id/labels/:label_id", (req, res, next) => + {project_id, label_id} = req.params + label = @labels[project_id]?[label_id] + if label? + @deleteLabel project_id, label_id + res.send 204 + else + res.send 404 + app.listen 3054, (error) -> throw error if error? .on "error", (error) -> console.error "error starting MockProjectHistoryApi:", error.message process.exit(1) - MockProjectHistoryApi.run() diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index f537614f17..eeafa6a44b 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee @@ -26,6 +26,14 @@ module.exports = MockV1Api = syncUserFeatures: sinon.stub() + affiliations: [] + + updateEmail: sinon.stub() + + existingEmails: [] + + setAffiliations: (affiliations) -> @affiliations = affiliations + run: () -> app.get "/api/v1/sharelatex/users/:v1_user_id/plan_code", (req, res, next) => user = @users[req.params.v1_user_id] @@ -43,7 +51,7 @@ module.exports = MockV1Api = res.json exportId: @exportId app.get "/api/v2/users/:userId/affiliations", (req, res, next) => - res.json [] + res.json @affiliations app.post "/api/v2/users/:userId/affiliations", (req, res, next) => res.sendStatus 201 @@ -60,6 +68,14 @@ module.exports = MockV1Api = app.get '/university/domains', (req, res, next) -> res.json [] + app.put '/api/v1/sharelatex/users/:id/email', (req, res, next) => + { email } = req.body?.user + if email in @existingEmails + return res.sendStatus 409 + else + @updateEmail parseInt(req.params.id), email + return res.sendStatus 200 + app.listen 5000, (error) -> throw error if error? .on "error", (error) -> diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 54b660c6b5..b8740f709c 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -100,6 +100,11 @@ class User @emails.push(email: email, createdAt: new Date()) UserUpdater.addEmailAddress @id, email, callback + confirmEmail: (email, callback = (error) ->) -> + for emailData, idx in @emails + @emails[idx].confirmedAt = new Date() if emailData.email == email + UserUpdater.confirmEmail @id, email, callback + ensure_admin: (callback = (error) ->) -> db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback diff --git a/services/web/test/acceptance/config/settings.test.coffee b/services/web/test/acceptance/config/settings.test.coffee index b64090da4b..4dd820f4a7 100644 --- a/services/web/test/acceptance/config/settings.test.coffee +++ b/services/web/test/acceptance/config/settings.test.coffee @@ -56,6 +56,7 @@ module.exports = defaultFeatures: features.personal defaultPlanCode: 'personal' + institutionPlanCode: 'professional' plans: plans = [{ planCode: "v1_free" diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee index f631dfe9a9..24af9971d2 100644 --- a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee +++ b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee @@ -15,7 +15,6 @@ describe "AuthenticationController", -> tk.freeze(Date.now()) @AuthenticationController = SandboxedModule.require modulePath, requires: "./AuthenticationManager": @AuthenticationManager = {} - "../User/UserGetter" : @UserGetter = {} "../User/UserUpdater" : @UserUpdater = {} "metrics-sharelatex": @Metrics = { inc: sinon.stub() } "../Security/LoginRateLimiter": @LoginRateLimiter = { processLoginRequest:sinon.stub(), recordSuccessfulLogin:sinon.stub() } @@ -29,6 +28,7 @@ describe "AuthenticationController", -> trackSession: sinon.stub() untrackSession: sinon.stub() revokeAllUserSessions: sinon.stub().callsArgWith(1, null) + "../../infrastructure/Modules": @Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, [])}} @user = _id: ObjectId() email: @email = "USER@example.com" @@ -98,6 +98,7 @@ describe "AuthenticationController", -> @req.session.destroy = sinon.stub().callsArgWith(0, null) @req.session.save = sinon.stub().callsArgWith(0, null) @req.sessionStore = {generate: sinon.stub()} + @AuthenticationController.finishLogin = sinon.stub() @passport.authenticate.callsArgWith(1, null, @user, @info) @err = new Error('woops') @@ -123,27 +124,10 @@ describe "AuthenticationController", -> afterEach -> delete @req.session.postLoginRedirect - it 'should call req.login', () -> + it 'should call finishLogin', () -> @AuthenticationController.passportLogin @req, @res, @next - @req.login.callCount.should.equal 1 - @req.login.calledWith(@user).should.equal true - - it 'should send a json response with redirect', () -> - @AuthenticationController.passportLogin @req, @res, @next - @res.json.callCount.should.equal 1 - @res.json.calledWith({redir: 'some_redirect'}).should.equal true - - describe 'when session.save produces an error', () -> - beforeEach -> - @req.session.save = sinon.stub().callsArgWith(0, new Error('woops')) - - it 'should return next with an error', () -> - @AuthenticationController.passportLogin @req, @res, @next - @next.calledWith(@err).should.equal true - - it 'should not return json', () -> - @AuthenticationController.passportLogin @req, @res, @next - @res.json.callCount.should.equal 0 + @AuthenticationController.finishLogin.callCount.should.equal 1 + @AuthenticationController.finishLogin.calledWith(@user).should.equal true describe 'when authenticate does not produce a user', -> @@ -151,9 +135,9 @@ describe "AuthenticationController", -> @info = {text: 'a', type: 'b'} @passport.authenticate.callsArgWith(1, null, false, @info) - it 'should not call req.login', () -> + it 'should not call finishLogin', () -> @AuthenticationController.passportLogin @req, @res, @next - @req.login.callCount.should.equal 0 + @AuthenticationController.finishLogin.callCount.should.equal 0 it 'should not send a json response with redirect', () -> @AuthenticationController.passportLogin @req, @res, @next @@ -230,6 +214,7 @@ describe "AuthenticationController", -> beforeEach -> @AuthenticationController._recordFailedLogin = sinon.stub() @AuthenticationController._recordSuccessfulLogin = sinon.stub() + @Modules.hooks.fire = sinon.stub().callsArgWith(3, null, []) # @AuthenticationController.establishUserSession = sinon.stub().callsArg(2) @req.body = email: @email @@ -238,6 +223,17 @@ describe "AuthenticationController", -> postLoginRedirect: "/path/to/redir/to" @cb = sinon.stub() + describe "when the preDoPassportLogin hooks produce an info object", -> + beforeEach -> + @Modules.hooks.fire = sinon.stub().callsArgWith(3, null, [null, {redir: '/somewhere'}, null]) + + it "should stop early and call done with this info object", (done) -> + @AuthenticationController.doPassportLogin(@req, @req.body.email, @req.body.password, @cb) + @cb.callCount.should.equal 1 + @cb.calledWith(null, false, {redir: '/somewhere'}).should.equal true + @LoginRateLimiter.processLoginRequest.callCount.should.equal 0 + done() + describe "when the users rate limit", -> beforeEach -> @@ -255,7 +251,6 @@ describe "AuthenticationController", -> @LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true) @AuthenticationManager.authenticate = sinon.stub().callsArgWith(2, null, @user) @req.sessionID = Math.random() - @AnalyticsManager.identifyUser = sinon.stub() @AuthenticationController.doPassportLogin(@req, @req.body.email, @req.body.password, @cb) it "should attempt to authorise the user", -> @@ -263,15 +258,24 @@ describe "AuthenticationController", -> .calledWith(email: @email.toLowerCase(), @password) .should.equal true + it "should establish the user's session", -> + @cb.calledWith(null, @user).should.equal true + + describe '_loginAsyncHandlers', -> + beforeEach -> + @UserHandler.setupLoginData = sinon.stub() + @LoginRateLimiter.recordSuccessfulLogin = sinon.stub() + @AuthenticationController._recordSuccessfulLogin = sinon.stub() + @AnalyticsManager.recordEvent = sinon.stub() + @AnalyticsManager.identifyUser = sinon.stub() + @AuthenticationController._loginAsyncHandlers(@req, @user) + it "should call identifyUser", -> @AnalyticsManager.identifyUser.calledWith(@user._id, @req.sessionID).should.equal true it "should setup the user data in the background", -> @UserHandler.setupLoginData.calledWith(@user).should.equal true - it "should establish the user's session", -> - @cb.calledWith(null, @user).should.equal true - it "should set res.session.justLoggedIn", -> @req.session.justLoggedIn.should.equal true @@ -281,11 +285,11 @@ describe "AuthenticationController", -> .should.equal true it "should tell the rate limiter that there was a success for that email", -> - @LoginRateLimiter.recordSuccessfulLogin.calledWith(@email.toLowerCase()).should.equal true + @LoginRateLimiter.recordSuccessfulLogin.calledWith(@user.email).should.equal true it "should log the successful login", -> @logger.log - .calledWith(email: @email.toLowerCase(), user_id: @user._id.toString(), "successful log in") + .calledWith(email: @user.email, user_id: @user._id.toString(), "successful log in") .should.equal true it "should track the login event", -> @@ -587,3 +591,56 @@ describe "AuthenticationController", -> @AuthenticationController._clearRedirectFromSession(@req) expect(@req.session.postLoginRedirect).to.equal undefined + + describe 'finishLogin', -> + # - get redirect + # - async handlers + # - afterLoginSessionSetup + # - clear redirect + # - issue redir, two ways + beforeEach -> + @AuthenticationController._getRedirectFromSession = sinon.stub().returns '/some/page' + @AuthenticationController._loginAsyncHandlers = sinon.stub() + @AuthenticationController.afterLoginSessionSetup = sinon.stub().callsArgWith(2, null) + @AuthenticationController._clearRedirectFromSession = sinon.stub() + @req.headers = {accept: 'application/json, whatever'} + @res.json = sinon.stub() + @res.redirect = sinon.stub() + + it 'should extract the redirect from the session', () -> + @AuthenticationController.finishLogin(@user, @req, @res, @next) + expect(@AuthenticationController._getRedirectFromSession.callCount).to.equal 1 + expect(@AuthenticationController._getRedirectFromSession.calledWith(@req)).to.equal true + + it 'should call the async handlers', () -> + @AuthenticationController.finishLogin(@user, @req, @res, @next) + expect(@AuthenticationController._loginAsyncHandlers.callCount).to.equal 1 + expect(@AuthenticationController._loginAsyncHandlers.calledWith(@req, @user)).to.equal true + + it 'should call afterLoginSessionSetup', () -> + @AuthenticationController.finishLogin(@user, @req, @res, @next) + expect(@AuthenticationController.afterLoginSessionSetup.callCount).to.equal 1 + expect(@AuthenticationController.afterLoginSessionSetup.calledWith(@req, @user)).to.equal true + + it 'should clear redirect from session', () -> + @AuthenticationController.finishLogin(@user, @req, @res, @next) + expect(@AuthenticationController._clearRedirectFromSession.callCount).to.equal 1 + expect(@AuthenticationController._clearRedirectFromSession.calledWith(@req)).to.equal true + + it 'should issue a json response with a redirect', () -> + @AuthenticationController.finishLogin(@user, @req, @res, @next) + expect(@res.json.callCount).to.equal 1 + expect(@res.redirect.callCount).to.equal 0 + expect(@res.json.calledWith({ redir: '/some/page' })).to.equal true + + describe 'with a non-json request', -> + beforeEach -> + @req.headers = {} + @res.json = sinon.stub() + @res.redirect = sinon.stub() + + it 'should issue a plain redirect', () -> + @AuthenticationController.finishLogin(@user, @req, @res, @next) + expect(@res.json.callCount).to.equal 0 + expect(@res.redirect.callCount).to.equal 1 + expect(@res.redirect.calledWith('/some/page')).to.equal true diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee index 703a03d14f..f1bf3792a2 100644 --- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee @@ -32,6 +32,7 @@ describe "EditorController", -> '../Project/ProjectEntityUpdateHandler' : @ProjectEntityUpdateHandler = {} '../Project/ProjectOptionsHandler' : @ProjectOptionsHandler = setCompiler: sinon.stub().yields() + setImageName: sinon.stub().yields() setSpellCheckLanguage: sinon.stub().yields() '../Project/ProjectDetailsHandler': @ProjectDetailsHandler = setProjectDescription: sinon.stub().yields() @@ -377,6 +378,19 @@ describe "EditorController", -> .calledWith(@project_id, "compilerUpdated", @compiler) .should.equal true + describe "setImageName", -> + beforeEach -> + @imageName = "texlive-1234.5" + @EditorController.setImageName @project_id, @imageName, @callback + + it "should send the new imageName and project id to the project options handler", -> + @ProjectOptionsHandler.setImageName + .calledWith(@project_id, @imageName) + .should.equal true + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "imageNameUpdated", @imageName) + .should.equal true + describe "setSpellCheckLanguage", -> beforeEach -> @languageCode = "fr" diff --git a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee index a210532e9b..8b16088552 100644 --- a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee @@ -63,6 +63,8 @@ describe 'ExportsHandler', -> beforeEach (done) -> @project = id: @project_id + compiler: 'pdflatex' + imageName: 'mock-image-name' overleaf: id: @project_history_id # for projects imported from v1 history: @@ -100,6 +102,9 @@ describe 'ExportsHandler', -> historyId: @project_history_id historyVersion: @historyVersion v1ProjectId: @project_history_id + metadata: + compiler: 'pdflatex' + imageName: 'mock-image-name' user: id: @user_id firstName: @user.first_name @@ -132,6 +137,9 @@ describe 'ExportsHandler', -> historyId: @project_history_id historyVersion: @historyVersion v1ProjectId: @project_history_id + metadata: + compiler: 'pdflatex' + imageName: 'mock-image-name' user: id: @user_id firstName: @custom_first_name @@ -273,3 +281,32 @@ describe 'ExportsHandler', -> @callback.calledWith(null, { body: @body }) .should.equal true + describe 'fetchZip', -> + beforeEach (done) -> + @settings.apis = + v1: + url: 'http://localhost:5000' + user: 'overleaf' + pass: 'pass' + @export_id = 897 + @body = "https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee" + @stubGet = sinon.stub().yields(null, {statusCode: 200}, { body: @body }) + done() + + describe "when all goes well", -> + beforeEach (done) -> + @stubRequest.get = @stubGet + @ExportsHandler.fetchZip @export_id, (error, body) => + @callback(error, body) + done() + + it 'should issue the request', -> + expect(@stubGet.getCall(0).args[0]).to.deep.equal + url: @settings.apis.v1.url + '/api/v1/sharelatex/exports/' + @export_id + '/zip_url' + auth: + user: @settings.apis.v1.user + pass: @settings.apis.v1.pass + + it 'should return the v1 export id', -> + @callback.calledWith(null, { body: @body }) + .should.equal true diff --git a/services/web/test/unit/coffee/User/UserAffiliationsManagerTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee similarity index 73% rename from services/web/test/unit/coffee/User/UserAffiliationsManagerTests.coffee rename to services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee index ff3d96a283..7500e46e40 100644 --- a/services/web/test/unit/coffee/User/UserAffiliationsManagerTests.coffee +++ b/services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee @@ -4,16 +4,16 @@ SandboxedModule = require('sandboxed-module') assert = require('assert') path = require('path') sinon = require('sinon') -modulePath = path.join __dirname, "../../../../app/js/Features/User/UserAffiliationsManager" +modulePath = path.join __dirname, "../../../../app/js/Features/Institutions/InstitutionsAPI" expect = require("chai").expect -describe "UserAffiliationsManager", -> +describe "InstitutionsAPI", -> beforeEach -> @logger = err: sinon.stub(), log: -> settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } } @request = sinon.stub() - @UserAffiliationsManager = SandboxedModule.require modulePath, requires: + @InstitutionsAPI = SandboxedModule.require modulePath, requires: "logger-sharelatex": @logger "metrics-sharelatex": timeAsyncMethod: sinon.stub() 'settings-sharelatex': settings @@ -25,11 +25,27 @@ describe "UserAffiliationsManager", -> email:"hello@world.com" @newEmail = "bob@bob.com" - describe 'getAffiliations', -> + describe 'getInstitutionAffiliations', -> + it 'get affiliations', (done)-> + @institutionId = 123 + responseBody = ['123abc', '456def'] + @request.yields(null, { statusCode: 200 }, responseBody) + @InstitutionsAPI.getInstitutionAffiliations @institutionId, (err, body) => + should.not.exist(err) + @request.calledOnce.should.equal true + requestOptions = @request.lastCall.args[0] + expectedUrl = "v1.url/api/v2/institutions/#{@institutionId}/affiliations" + requestOptions.url.should.equal expectedUrl + requestOptions.method.should.equal 'GET' + should.not.exist(requestOptions.body) + body.should.equal responseBody + done() + + describe 'getUserAffiliations', -> it 'get affiliations', (done)-> responseBody = [{ foo: 'bar' }] @request.callsArgWith(1, null, { statusCode: 201 }, responseBody) - @UserAffiliationsManager.getAffiliations @stubbedUser._id, (err, body) => + @InstitutionsAPI.getUserAffiliations @stubbedUser._id, (err, body) => should.not.exist(err) @request.calledOnce.should.equal true requestOptions = @request.lastCall.args[0] @@ -43,7 +59,7 @@ describe "UserAffiliationsManager", -> it 'handle error', (done)-> body = errors: 'affiliation error message' @request.callsArgWith(1, null, { statusCode: 503 }, body) - @UserAffiliationsManager.getAffiliations @stubbedUser._id, (err) => + @InstitutionsAPI.getUserAffiliations @stubbedUser._id, (err) => should.exist(err) err.message.should.have.string 503 err.message.should.have.string body.errors @@ -58,7 +74,7 @@ describe "UserAffiliationsManager", -> university: { id: 1 } role: 'Prof' department: 'Math' - @UserAffiliationsManager.addAffiliation @stubbedUser._id, @newEmail, affiliationOptions, (err)=> + @InstitutionsAPI.addAffiliation @stubbedUser._id, @newEmail, affiliationOptions, (err)=> should.not.exist(err) @request.calledOnce.should.equal true requestOptions = @request.lastCall.args[0] @@ -77,7 +93,7 @@ describe "UserAffiliationsManager", -> it 'handle error', (done)-> body = errors: 'affiliation error message' @request.callsArgWith(1, null, { statusCode: 422 }, body) - @UserAffiliationsManager.addAffiliation @stubbedUser._id, @newEmail, {}, (err)=> + @InstitutionsAPI.addAffiliation @stubbedUser._id, @newEmail, {}, (err)=> should.exist(err) err.message.should.have.string 422 err.message.should.have.string body.errors @@ -88,7 +104,7 @@ describe "UserAffiliationsManager", -> @request.callsArgWith(1, null, { statusCode: 404 }) it 'remove affiliation', (done)-> - @UserAffiliationsManager.removeAffiliation @stubbedUser._id, @newEmail, (err)=> + @InstitutionsAPI.removeAffiliation @stubbedUser._id, @newEmail, (err)=> should.not.exist(err) @request.calledOnce.should.equal true requestOptions = @request.lastCall.args[0] @@ -100,7 +116,7 @@ describe "UserAffiliationsManager", -> it 'handle error', (done)-> @request.callsArgWith(1, null, { statusCode: 500 }) - @UserAffiliationsManager.removeAffiliation @stubbedUser._id, @newEmail, (err)=> + @InstitutionsAPI.removeAffiliation @stubbedUser._id, @newEmail, (err)=> should.exist(err) err.message.should.exist done() @@ -108,7 +124,7 @@ describe "UserAffiliationsManager", -> describe 'deleteAffiliations', -> it 'delete affiliations', (done)-> @request.callsArgWith(1, null, { statusCode: 200 }) - @UserAffiliationsManager.deleteAffiliations @stubbedUser._id, (err) => + @InstitutionsAPI.deleteAffiliations @stubbedUser._id, (err) => should.not.exist(err) @request.calledOnce.should.equal true requestOptions = @request.lastCall.args[0] @@ -120,7 +136,7 @@ describe "UserAffiliationsManager", -> it 'handle error', (done)-> body = errors: 'affiliation error message' @request.callsArgWith(1, null, { statusCode: 518 }, body) - @UserAffiliationsManager.deleteAffiliations @stubbedUser._id, (err) => + @InstitutionsAPI.deleteAffiliations @stubbedUser._id, (err) => should.exist(err) err.message.should.have.string 518 err.message.should.have.string body.errors @@ -131,7 +147,7 @@ describe "UserAffiliationsManager", -> @request.callsArgWith(1, null, { statusCode: 204 }) it 'endorse affiliation', (done)-> - @UserAffiliationsManager.endorseAffiliation @stubbedUser._id, @newEmail, 'Student','Physics', (err)=> + @InstitutionsAPI.endorseAffiliation @stubbedUser._id, @newEmail, 'Student','Physics', (err)=> should.not.exist(err) @request.calledOnce.should.equal true requestOptions = @request.lastCall.args[0] diff --git a/services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee new file mode 100644 index 0000000000..8811f72c35 --- /dev/null +++ b/services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee @@ -0,0 +1,86 @@ +SandboxedModule = require('sandboxed-module') +assert = require('assert') +require('chai').should() +expect = require('chai').expect +sinon = require('sinon') +modulePath = require('path').join __dirname, '../../../../app/js/Features/Institutions/InstitutionsFeatures.js' + +describe 'InstitutionsFeatures', -> + + beforeEach -> + @InstitutionsGetter = getConfirmedInstitutions: sinon.stub() + @PlansLocator = findLocalPlanInSettings: sinon.stub() + @institutionPlanCode = 'institution_plan_code' + @InstitutionsFeatures = SandboxedModule.require modulePath, requires: + './InstitutionsGetter': @InstitutionsGetter + '../Subscription/PlansLocator': @PlansLocator + 'settings-sharelatex': institutionPlanCode: @institutionPlanCode + 'logger-sharelatex': + log:-> + err:-> + + @userId = '12345abcde' + + describe "hasLicence", -> + it 'should handle error', (done)-> + @InstitutionsGetter.getConfirmedInstitutions.yields(new Error('Nope')) + @InstitutionsFeatures.hasLicence @userId, (error, hasLicence) -> + expect(error).to.exist + done() + + it 'should return false if user has no confirmed affiliations', (done) -> + institutions = [] + @InstitutionsGetter.getConfirmedInstitutions.yields(null, institutions) + @InstitutionsFeatures.hasLicence @userId, (error, hasLicence) -> + expect(error).to.not.exist + expect(hasLicence).to.be.false + done() + + it 'should return false if user has no paid affiliations', (done) -> + institutions = [ + { licence: 'free' } + ] + @InstitutionsGetter.getConfirmedInstitutions.yields(null, institutions) + @InstitutionsFeatures.hasLicence @userId, (error, hasLicence) -> + expect(error).to.not.exist + expect(hasLicence).to.be.false + done() + + it 'should return true if user has confirmed paid affiliation', (done)-> + institutions = [ + { licence: 'pro_plus' } + { licence: 'free' } + { licence: 'pro' } + { licence: null } + ] + @InstitutionsGetter.getConfirmedInstitutions.yields(null, institutions) + @InstitutionsFeatures.hasLicence @userId, (error, hasLicence) -> + expect(error).to.not.exist + expect(hasLicence).to.be.true + done() + + describe "getInstitutionsFeatures", -> + beforeEach -> + @InstitutionsFeatures.hasLicence = sinon.stub() + @testFeatures = features: { institution: 'all' } + @PlansLocator.findLocalPlanInSettings.withArgs(@institutionPlanCode).returns(@testFeatures) + + it 'should handle error', (done)-> + @InstitutionsFeatures.hasLicence.yields(new Error('Nope')) + @InstitutionsFeatures.getInstitutionsFeatures @userId, (error, features) -> + expect(error).to.exist + done() + + it 'should return no feaures if user has no plan code', (done) -> + @InstitutionsFeatures.hasLicence.yields(null, false) + @InstitutionsFeatures.getInstitutionsFeatures @userId, (error, features) -> + expect(error).to.not.exist + expect(features).to.deep.equal {} + done() + + it 'should return feaures if user has affiliations plan code', (done) -> + @InstitutionsFeatures.hasLicence.yields(null, true) + @InstitutionsFeatures.getInstitutionsFeatures @userId, (error, features) => + expect(error).to.not.exist + expect(features).to.deep.equal @testFeatures.features + done() diff --git a/services/web/test/unit/coffee/Institutions/InstitutionsGetterTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsGetterTests.coffee new file mode 100644 index 0000000000..62c57cdaa9 --- /dev/null +++ b/services/web/test/unit/coffee/Institutions/InstitutionsGetterTests.coffee @@ -0,0 +1,44 @@ +SandboxedModule = require('sandboxed-module') +require('chai').should() +expect = require('chai').expect +sinon = require('sinon') +modulePath = require('path').join __dirname, '../../../../app/js/Features/Institutions/InstitutionsGetter.js' + +describe 'InstitutionsGetter', -> + beforeEach -> + @UserGetter = getUserFullEmails: sinon.stub() + @InstitutionsGetter = SandboxedModule.require modulePath, requires: + '../User/UserGetter': @UserGetter + 'logger-sharelatex': + log:-> console.log(arguments) + err:-> + + @userId = '12345abcde' + + describe "getConfirmedInstitutions", -> + it 'filters unconfirmed emails', (done) -> + @userEmails = [ + { confirmedAt: null, affiliation: institution: { id: 123 } } + { confirmedAt: new Date(), affiliation: institution: { id: 456 } } + { confirmedAt: new Date(), affiliation: null } + { confirmedAt: new Date(), affiliation: institution: null } + ] + @UserGetter.getUserFullEmails.yields(null, @userEmails) + @InstitutionsGetter.getConfirmedInstitutions @userId, (error, institutions) -> + expect(error).to.not.exist + institutions.length.should.equal 1 + institutions[0].id.should.equal 456 + done() + + it 'should handle empty response', (done) -> + @UserGetter.getUserFullEmails.yields(null, []) + @InstitutionsGetter.getConfirmedInstitutions @userId, (error, institutions) -> + expect(error).to.not.exist + institutions.length.should.equal 0 + done() + + it 'should handle error', (done) -> + @UserGetter.getUserFullEmails.yields(new Error('Nope')) + @InstitutionsGetter.getConfirmedInstitutions @userId, (error, institutions) -> + expect(error).to.exist + done() diff --git a/services/web/test/unit/coffee/Institutions/InstitutionsManagerTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsManagerTests.coffee new file mode 100644 index 0000000000..acf0478ae2 --- /dev/null +++ b/services/web/test/unit/coffee/Institutions/InstitutionsManagerTests.coffee @@ -0,0 +1,30 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +path = require('path') +sinon = require('sinon') +modulePath = path.join __dirname, "../../../../app/js/Features/Institutions/InstitutionsManager" + +describe "InstitutionsManager", -> + beforeEach -> + @institutionId = 123 + @logger = log: -> + @getInstitutionAffiliations = sinon.stub() + @refreshFeatures = sinon.stub().yields() + @InstitutionsManager = SandboxedModule.require modulePath, requires: + 'logger-sharelatex': @logger + './InstitutionsAPI': + getInstitutionAffiliations: @getInstitutionAffiliations + '../Subscription/FeaturesUpdater': + refreshFeatures: @refreshFeatures + + describe 'upgradeInstitutionUsers', -> + it 'refresh all users Features', (done) -> + affiliations = [ + { user_id: '123abc123abc123abc123abc' } + { user_id: '456def456def456def456def' } + ] + @getInstitutionAffiliations.yields(null, affiliations) + @InstitutionsManager.upgradeInstitutionUsers @institutionId, (error) => + should.not.exist(error) + sinon.assert.calledTwice(@refreshFeatures) + done() diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index bfd67e42d2..46e54501c9 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -72,6 +72,8 @@ describe "ProjectController", -> @Modules = hooks: fire: sinon.stub() + @Features = + hasFeature: sinon.stub() @ProjectController = SandboxedModule.require modulePath, requires: "settings-sharelatex":@settings "logger-sharelatex": @@ -102,6 +104,7 @@ describe "ProjectController", -> "../../infrastructure/Modules": @Modules "./ProjectEntityHandler": @ProjectEntityHandler "../Errors/Errors": Errors + "../../infrastructure/Features": @Features @projectName = "£12321jkj9ujkljds" @req = @@ -142,6 +145,18 @@ describe "ProjectController", -> done() @ProjectController.updateProjectSettings @req, @res + it "should update the imageName", (done) -> + @EditorController.setImageName = sinon.stub().callsArg(2) + @req.body = + imageName: @imageName = "texlive-1234.5" + @res.sendStatus = (code) => + @EditorController.setImageName + .calledWith(@project_id, @imageName) + .should.equal true + code.should.equal 204 + done() + @ProjectController.updateProjectSettings @req, @res + it "should update the spell check language", (done) -> @EditorController.setSpellCheckLanguage = sinon.stub().callsArg(2) @req.body = diff --git a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee index 6d86545622..15499e6f71 100644 --- a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee @@ -111,7 +111,7 @@ describe 'ProjectCreationHandler', -> project.spellCheckLanguage.should.equal "de" done() - it "should set the imageName to currentImageName if set", (done) -> + it "should set the imageName to currentImageName if set and no imageName attribute", (done) -> @Settings.currentImageName = "mock-image-name" @handler.createBlankProject ownerId, projectName, (err, project)=> project.imageName.should.equal @Settings.currentImageName @@ -123,6 +123,14 @@ describe 'ProjectCreationHandler', -> expect(project.imageName).to.not.exist done() + it "should set the imageName to the attribute value if set and not overwrite it with the currentImageName", (done) -> + @Settings.currentImageName = "mock-image-name" + attributes = + imageName: "attribute-image-name" + @handler.createBlankProject ownerId, projectName, attributes, (err, project)=> + project.imageName.should.equal attributes.imageName + done() + it "should not set the overleaf.history.display if not configured in settings", (done) -> @Settings.apis.project_history.displayHistoryForNewProjects = false @handler.createBlankProject ownerId, projectName, (err, project)=> diff --git a/services/web/test/unit/coffee/Project/ProjectOptionsHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectOptionsHandlerTests.coffee index 825822c236..435dba9dbc 100644 --- a/services/web/test/unit/coffee/Project/ProjectOptionsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectOptionsHandlerTests.coffee @@ -19,6 +19,11 @@ describe 'creating a project', -> {name: "English", code: "en"} {name: "French", code: "fr"} ] + imageRoot: "docker-repo/subdir" + allowedImageNames: [ + {imageName: "texlive-0000.0", imageDesc: "test image 0"} + {imageName: "texlive-1234.5", imageDesc: "test image 1"} + ] 'logger-sharelatex': log:-> err:-> @@ -37,6 +42,19 @@ describe 'creating a project', -> @projectModel.update.called.should.equal false done() + describe 'Setting the imageName', -> + it 'should perform and update on mongo', (done)-> + @handler.setImageName project_id, "texlive-1234.5", (err)=> + args = @projectModel.update.args[0] + args[0]._id.should.equal project_id + args[1].imageName.should.equal "docker-repo/subdir/texlive-1234.5" + done() + @projectModel.update.args[0][3]() + + it 'should not perform and update on mongo if it is not a reconised compiler', (done)-> + @handler.setImageName project_id, "something", (err)=> + @projectModel.update.called.should.equal false + done() describe "setting the spellCheckLanguage", -> diff --git a/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee index 489c5676ff..0b4378c5e2 100644 --- a/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee +++ b/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee @@ -19,6 +19,7 @@ describe "FeaturesUpdater", -> 'settings-sharelatex': @Settings = {} "../Referal/ReferalFeatures" : @ReferalFeatures = {} "./V1SubscriptionManager": @V1SubscriptionManager = {} + '../Institutions/InstitutionsFeatures': @InstitutionsFeatures = {} describe "refreshFeatures", -> beforeEach -> @@ -26,6 +27,7 @@ describe "FeaturesUpdater", -> @UserFeaturesUpdater.updateFeatures = sinon.stub().yields() @FeaturesUpdater._getIndividualFeatures = sinon.stub().yields(null, { 'individual': 'features' }) @FeaturesUpdater._getGroupFeatureSets = sinon.stub().yields(null, [{ 'group': 'features' }, { 'group': 'features2' }]) + @InstitutionsFeatures.getInstitutionsFeatures = sinon.stub().yields(null, { 'institutions': 'features' }) @FeaturesUpdater._getV1Features = sinon.stub().yields(null, { 'v1': 'features' }) @ReferalFeatures.getBonusFeatures = sinon.stub().yields(null, { 'bonus': 'features' }) @FeaturesUpdater._mergeFeatures = sinon.stub().returns({'merged': 'features'}) @@ -45,6 +47,11 @@ describe "FeaturesUpdater", -> .calledWith(@user_id) .should.equal true + it "should get the institution features", -> + @InstitutionsFeatures.getInstitutionsFeatures + .calledWith(@user_id) + .should.equal true + it "should get the v1 features", -> @FeaturesUpdater._getV1Features .calledWith(@user_id) @@ -65,6 +72,9 @@ describe "FeaturesUpdater", -> @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features' }).should.equal true @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features2' }).should.equal true + it "should merge the institutions features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'institutions': 'features' }).should.equal true + it "should merge the v1 features", -> @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'v1': 'features' }).should.equal true diff --git a/services/web/test/unit/coffee/Subscription/LimitationsManagerTests.coffee b/services/web/test/unit/coffee/Subscription/LimitationsManagerTests.coffee index 4b47250d4a..9d87870121 100644 --- a/services/web/test/unit/coffee/Subscription/LimitationsManagerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/LimitationsManagerTests.coffee @@ -23,6 +23,7 @@ describe "LimitationsManager", -> @SubscriptionLocator = getUsersSubscription: sinon.stub() + getSubscription: sinon.stub() @LimitationsManager = SandboxedModule.require modulePath, requires: '../Project/ProjectGetter': @ProjectGetter @@ -37,14 +38,14 @@ describe "LimitationsManager", -> describe "allowedNumberOfCollaboratorsInProject", -> describe "when the project is owned by a user without a subscription", -> beforeEach -> - @Settings.defaultPlanCode = collaborators: 23 + @Settings.defaultFeatures = collaborators: 23 @project.owner_ref = @user_id delete @user.features @callback = sinon.stub() @LimitationsManager.allowedNumberOfCollaboratorsInProject(@project_id, @callback) it "should return the default number", -> - @callback.calledWith(null, @Settings.defaultPlanCode.collaborators).should.equal true + @callback.calledWith(null, @Settings.defaultFeatures.collaborators).should.equal true describe "when the project is owned by a user with a subscription", -> beforeEach -> @@ -60,13 +61,13 @@ describe "LimitationsManager", -> describe "allowedNumberOfCollaboratorsForUser", -> describe "when the user has no features", -> beforeEach -> - @Settings.defaultPlanCode = collaborators: 23 + @Settings.defaultFeatures = collaborators: 23 delete @user.features @callback = sinon.stub() @LimitationsManager.allowedNumberOfCollaboratorsForUser(@user_id, @callback) it "should return the default number", -> - @callback.calledWith(null, @Settings.defaultPlanCode.collaborators).should.equal true + @callback.calledWith(null, @Settings.defaultFeatures.collaborators).should.equal true describe "when the user has features", -> beforeEach -> @@ -301,7 +302,7 @@ describe "LimitationsManager", -> describe "hasGroupMembersLimitReached", -> beforeEach -> - @user_id = "12312" + @subscriptionId = "12312" @subscription = membersLimit: 3 member_ids: ["", ""] @@ -310,22 +311,22 @@ describe "LimitationsManager", -> ] it "should return true if the limit is hit (including members and invites)", (done)-> - @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) - @LimitationsManager.hasGroupMembersLimitReached @user_id, (err, limitReached)-> + @SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription) + @LimitationsManager.hasGroupMembersLimitReached @subscriptionId, (err, limitReached)-> limitReached.should.equal true done() it "should return false if the limit is not hit (including members and invites)", (done)-> @subscription.membersLimit = 4 - @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) - @LimitationsManager.hasGroupMembersLimitReached @user_id, (err, limitReached)-> + @SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription) + @LimitationsManager.hasGroupMembersLimitReached @subscriptionId, (err, limitReached)-> limitReached.should.equal false done() it "should return true if the limit has been exceded (including members and invites)", (done)-> @subscription.membersLimit = 2 - @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) - @LimitationsManager.hasGroupMembersLimitReached @user_id, (err, limitReached)-> + @SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription) + @LimitationsManager.hasGroupMembersLimitReached @subscriptionId, (err, limitReached)-> limitReached.should.equal true done() @@ -379,4 +380,3 @@ describe "LimitationsManager", -> @V1SubscriptionManager.getSubscriptionsFromV1.calledWith(@user_id).should.equal true result.should.equal false done() - diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee index d9ad55080f..a399496399 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee @@ -10,7 +10,7 @@ describe "SubscriptionGroupController", -> beforeEach -> @user = {_id:"!@312431",email:"user@email.com"} @adminUserId = "123jlkj" - @subscription_id = "123434325412" + @subscriptionId = "123434325412" @user_email = "bob@gmail.com" @req = session: @@ -18,15 +18,19 @@ describe "SubscriptionGroupController", -> _id: @adminUserId email:@user_email params: - subscription_id:@subscription_id + subscriptionId:@subscriptionId query:{} - @subscription = {} + @subscription = { + _id: @subscriptionId + } @GroupHandler = addUserToGroup: sinon.stub().callsArgWith(2, null, @user) removeUserFromGroup: sinon.stub().callsArgWith(2) isUserPartOfGroup: sinon.stub() getPopulatedListOfMembers: sinon.stub().callsArgWith(1, null, [@user]) - @SubscriptionLocator = getUsersSubscription: sinon.stub().callsArgWith(1, null, @subscription) + @SubscriptionLocator = + findManagedSubscription: sinon.stub().callsArgWith(1, null, @subscription) + @AuthenticationController = getLoggedInUserId: (req) -> req.session.user._id getSessionUser: (req) -> req.session.user @@ -55,25 +59,25 @@ describe "SubscriptionGroupController", -> describe "addUserToGroup", -> - it "should use the admin id for the logged in user and take the email address from the body", (done)-> + it "should use the subscription id for the logged in user and take the email address from the body", (done)-> newEmail = " boB@gmaiL.com " @req.body = email: newEmail res = json : (data)=> - @GroupHandler.addUserToGroup.calledWith(@adminUserId, "bob@gmail.com").should.equal true + @GroupHandler.addUserToGroup.calledWith(@subscriptionId, "bob@gmail.com").should.equal true data.user.should.deep.equal @user done() @Controller.addUserToGroup @req, res describe "removeUserFromGroup", -> - it "should use the admin id for the logged in user and take the user id from the params", (done)-> + it "should use the subscription id for the logged in user and take the user id from the params", (done)-> userIdToRemove = "31231" @req.params = user_id: userIdToRemove res = send : => - @GroupHandler.removeUserFromGroup.calledWith(@adminUserId, userIdToRemove).should.equal true + @GroupHandler.removeUserFromGroup.calledWith(@subscriptionId, userIdToRemove).should.equal true done() @Controller.removeUserFromGroup @req, res diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index e5a5695cc1..78f6ac3805 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -16,7 +16,8 @@ describe "SubscriptionGroupHandler", -> @subscription_id = "31DSd1123D" @subscription = - admin_id:@adminUser_id + admin_id: @adminUser_id + manager_ids: [@adminUser_id] _id:@subscription_id @SubscriptionLocator = @@ -81,15 +82,15 @@ describe "SubscriptionGroupHandler", -> beforeEach -> @LimitationsManager.hasGroupMembersLimitReached.callsArgWith(1, null, false, @subscription) @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @user) - + it "should find the user", (done)-> @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> @UserGetter.getUserByAnyEmail.calledWith(@newEmail).should.equal true done() it "should add the user to the group", (done)-> - @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> - @SubscriptionUpdater.addUserToGroup.calledWith(@adminUser_id, @user._id).should.equal true + @Handler.addUserToGroup @subscription_id, @newEmail, (err)=> + @SubscriptionUpdater.addUserToGroup.calledWith(@subscription_id, @user._id).should.equal true done() it "should not add the user to the group if the limit has been reached", (done)-> @@ -124,45 +125,48 @@ describe "SubscriptionGroupHandler", -> done() describe "replaceUserReferencesInGroups", -> - beforeEach -> + beforeEach (done)-> @oldId = "ba5eba11" @newId = "5ca1ab1e" + @Handler.replaceUserReferencesInGroups @oldId, @newId, -> + done() - it "replaces the admin_id", (done) -> - @Handler.replaceUserReferencesInGroups @oldId, @newId, (err) => - + it "replaces the admin_id", -> @Subscription.update.calledWith( { admin_id: @oldId }, { admin_id: @newId } ).should.equal true - done() - - it "replaces the member ids", (done) -> - @Handler.replaceUserReferencesInGroups @oldId, @newId, (err) => - + it "replaces the manager_ids", -> @Subscription.update.calledWith( - { member_ids: @oldId }, - { $addToSet: { member_ids: @newId } } + {manager_ids:"ba5eba11"},{$addToSet:{manager_ids:"5ca1ab1e"}},{multi:true} ).should.equal true @Subscription.update.calledWith( - { member_ids: @oldId }, - { $pull: { member_ids: @oldId } } + {manager_ids:"ba5eba11"},{$pull:{manager_ids:"ba5eba11"}},{multi:true} ).should.equal true - done() + it "replaces the member ids", -> + @Subscription.update.calledWith( + { member_ids: @oldId }, + { $addToSet: { member_ids: @newId } } + ).should.equal true + + @Subscription.update.calledWith( + { member_ids: @oldId }, + { $pull: { member_ids: @oldId } } + ).should.equal true describe "getPopulatedListOfMembers", -> beforeEach -> @subscription = {} - @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @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 @adminUser_id, (err, users)=> - @SubscriptionLocator.getUsersSubscription.calledWith(@adminUser_id).should.equal true + @Handler.getPopulatedListOfMembers @subscriptionId, (err, users)=> + @SubscriptionLocator.getSubscription.calledWith(@subscriptionId).should.equal true done() it "should get the users by id", (done)-> diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionLocatorTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionLocatorTests.coffee index 4dbbba5a8c..52dee42e84 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionLocatorTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionLocatorTests.coffee @@ -3,7 +3,7 @@ should = require('chai').should() sinon = require 'sinon' modulePath = "../../../../app/js/Features/Subscription/SubscriptionLocator" assert = require("chai").assert -ObjectId = require('mongoose').Types.ObjectId +ObjectId = require('mongoose').Types.ObjectId describe "Subscription Locator Tests", -> @@ -41,3 +41,11 @@ describe "Subscription Locator Tests", -> subscription.should.equal @subscription done() + describe "finding managed subscription", -> + + it "should query the database", (done)-> + @Subscription.findOne.callsArgWith(1, null, @subscription) + @SubscriptionLocator.findManagedSubscription @user._id, (err, subscription)=> + @Subscription.findOne.calledWith({"manager_ids":@user._id}).should.equal true + subscription.should.equal @subscription + done() diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee index f97ba98ad4..a66d7f5dec 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee @@ -18,7 +18,9 @@ describe "SubscriptionUpdater", -> @otherUserId = "5208dd34438842e2db000005" @allUserIds = ["13213", "dsadas", "djsaiud89"] @subscription = subscription = + _id: "111111111111111111111111" admin_id: @adminUser._id + manager_ids: [@adminUser._id] member_ids: @allUserIds save: sinon.stub().callsArgWith(0) freeTrial:{} @@ -26,7 +28,9 @@ describe "SubscriptionUpdater", -> @user_id = @adminuser_id @groupSubscription = + _id: "222222222222222222222222" admin_id: @adminUser._id + manager_ids: [@adminUser._id] member_ids: @allUserIds save: sinon.stub().callsArgWith(0) freeTrial:{} @@ -38,6 +42,7 @@ describe "SubscriptionUpdater", -> @SubscriptionModel = class constructor: (opts)-> subscription.admin_id = opts.admin_id + subscription.manager_ids = [opts.admin_id] return subscription @remove: sinon.stub().yields() @SubscriptionModel.update = @updateStub @@ -103,6 +108,7 @@ describe "SubscriptionUpdater", -> describe "_updateSubscriptionFromRecurly", -> beforeEach -> @FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1) + @SubscriptionUpdater.deleteSubscription = sinon.stub().yields() it "should update the subscription with token etc when not expired", (done)-> @SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=> @@ -116,13 +122,10 @@ describe "SubscriptionUpdater", -> @FeaturesUpdater.refreshFeatures.calledWith(@adminUser._id).should.equal true done() - it "should remove the recurlySubscription_id when expired", (done)-> + it "should remove the subscription when expired", (done)-> @recurlySubscription.state = "expired" - @SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=> - assert.equal(@subscription.recurlySubscription_id, undefined) - @subscription.save.called.should.equal true - @FeaturesUpdater.refreshFeatures.calledWith(@adminUser._id).should.equal true + @SubscriptionUpdater.deleteSubscription.calledWith(@subscription._id).should.equal true done() it "should update all the users features", (done)-> @@ -151,6 +154,7 @@ describe "SubscriptionUpdater", -> it "should create a new subscription then update the subscription", (done)-> @SubscriptionUpdater._createNewSubscription @adminUser._id, => @subscription.admin_id.should.equal @adminUser._id + @subscription.manager_ids.should.deep.equal [@adminUser._id] @subscription.freeTrial.allowed.should.equal false @subscription.save.called.should.equal true done() @@ -160,9 +164,9 @@ describe "SubscriptionUpdater", -> @SubscriptionUpdater.addUsersToGroup = sinon.stub().yields(null) it "delegates to addUsersToGroup", (done)-> - @SubscriptionUpdater.addUserToGroup @adminUser._id, @otherUserId, => + @SubscriptionUpdater.addUserToGroup @subscription._id, @otherUserId, => @SubscriptionUpdater.addUsersToGroup - .calledWith(@adminUser._id, [@otherUserId]).should.equal true + .calledWith(@subscription._id, [@otherUserId]).should.equal true done() describe "addUsersToGroup", -> @@ -170,16 +174,16 @@ describe "SubscriptionUpdater", -> @FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1) it "should add the user ids to the group as a set", (done)-> - @SubscriptionUpdater.addUsersToGroup @adminUser._id, [@otherUserId], => + @SubscriptionUpdater.addUsersToGroup @subscription._id, [@otherUserId], => searchOps = - admin_id: @adminUser._id + _id: @subscription._id insertOperation = { $push: { member_ids: { $each: [@otherUserId] } } } @findAndModifyStub.calledWith(searchOps, insertOperation).should.equal true done() it "should update the users features", (done)-> - @SubscriptionUpdater.addUserToGroup @adminUser._id, @otherUserId, => + @SubscriptionUpdater.addUserToGroup @subscription._id, @otherUserId, => @FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true done() @@ -188,16 +192,16 @@ describe "SubscriptionUpdater", -> @FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1) it "should pull the users id from the group", (done)-> - @SubscriptionUpdater.removeUserFromGroup @adminUser._id, @otherUserId, => + @SubscriptionUpdater.removeUserFromGroup @subscription._id, @otherUserId, => searchOps = - admin_id:@adminUser._id + _id: @subscription._id removeOperation = "$pull": {member_ids:@otherUserId} @updateStub.calledWith(searchOps, removeOperation).should.equal true done() it "should update the users features", (done)-> - @SubscriptionUpdater.removeUserFromGroup @adminUser._id, @otherUserId, => + @SubscriptionUpdater.removeUserFromGroup @subscription._id, @otherUserId, => @FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true done() diff --git a/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee index d23d1fc3a6..e6f404516e 100644 --- a/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee @@ -26,6 +26,7 @@ describe "TeamInvitesHandler", -> @subscription = { id: "55153a8014829a865bbf700d", + _id: new ObjectId("55153a8014829a865bbf700d"), admin_id: @manager.id, groupPlan: true, member_ids: [], @@ -210,7 +211,7 @@ describe "TeamInvitesHandler", -> it "adds the user to the team", (done) -> @TeamInvitesHandler.acceptInvite "dddddddd", @user.id, => - @SubscriptionUpdater.addUserToGroup.calledWith(@manager.id, @user.id).should.eq true + @SubscriptionUpdater.addUserToGroup.calledWith(@subscription._id, @user.id).should.eq true done() it "removes the invite from the subscription", (done) -> diff --git a/services/web/test/unit/coffee/User/UserControllerTests.coffee b/services/web/test/unit/coffee/User/UserControllerTests.coffee index 26f345db39..d589852a37 100644 --- a/services/web/test/unit/coffee/User/UserControllerTests.coffee +++ b/services/web/test/unit/coffee/User/UserControllerTests.coffee @@ -9,6 +9,7 @@ MockResponse = require "../helpers/MockResponse" MockRequest = require "../helpers/MockRequest" ObjectId = require("mongojs").ObjectId assert = require("assert") +Errors = require "../../../../app/js/Features/Errors/Errors" describe "UserController", -> beforeEach -> @@ -81,6 +82,7 @@ describe "UserController", -> log:-> err:-> "metrics-sharelatex": inc:-> + "../Errors/Errors": Errors @res = send: sinon.stub() diff --git a/services/web/test/unit/coffee/User/UserCreatorTests.coffee b/services/web/test/unit/coffee/User/UserCreatorTests.coffee index 1945453b9e..d9118e450f 100644 --- a/services/web/test/unit/coffee/User/UserCreatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserCreatorTests.coffee @@ -20,9 +20,9 @@ describe "UserCreator", -> @addAffiliation = sinon.stub().yields() @UserCreator = SandboxedModule.require modulePath, requires: "../../models/User": User:@UserModel - "logger-sharelatex":{log:->} + "logger-sharelatex":{ log: sinon.stub(), err: sinon.stub() } 'metrics-sharelatex': {timeAsyncMethod: ()->} - "./UserAffiliationsManager": addAffiliation: @addAffiliation + "../Institutions/InstitutionsAPI": addAffiliation: @addAffiliation @email = "bob.oswald@gmail.com" @@ -88,3 +88,11 @@ describe "UserCreator", -> process.nextTick () => sinon.assert.calledWith(@addAffiliation, user._id, user.email) done() + + it "should not add affiliation if skipping", (done)-> + attributes = email: @email + options = skip_affiliation: true + @UserCreator.createNewUser attributes, options, (err, user) => + process.nextTick () => + sinon.assert.notCalled(@addAffiliation) + done() diff --git a/services/web/test/unit/coffee/User/UserDeleterTests.coffee b/services/web/test/unit/coffee/User/UserDeleterTests.coffee index d2be2d211b..fc5f2ef370 100644 --- a/services/web/test/unit/coffee/User/UserDeleterTests.coffee +++ b/services/web/test/unit/coffee/User/UserDeleterTests.coffee @@ -31,7 +31,7 @@ describe "UserDeleter", -> "../Newsletter/NewsletterManager": @NewsletterManager "../Subscription/SubscriptionHandler": @SubscriptionHandler "../Project/ProjectDeleter": @ProjectDeleter - "./UserAffiliationsManager": + "../Institutions/InstitutionsAPI": deleteAffiliations: @deleteAffiliations "logger-sharelatex": @logger = { log: sinon.stub() } diff --git a/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee b/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee index 384623a2b5..800a44336f 100644 --- a/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee +++ b/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee @@ -24,6 +24,7 @@ describe "UserEmailsController", -> addEmailAddress: sinon.stub() removeEmailAddress: sinon.stub() setDefaultEmailAddress: sinon.stub() + updateV1AndSetDefaultEmailAddress: sinon.stub() @EmailHelper = parseEmail: sinon.stub() @endorseAffiliation = sinon.stub().yields() @@ -33,7 +34,7 @@ describe "UserEmailsController", -> "./UserUpdater": @UserUpdater "../Helpers/EmailHelper": @EmailHelper "./UserEmailsConfirmationHandler": @UserEmailsConfirmationHandler = {} - "./UserAffiliationsManager": endorseAffiliation: @endorseAffiliation + "../Institutions/InstitutionsAPI": endorseAffiliation: @endorseAffiliation "../Errors/Errors": Errors "logger-sharelatex": log: -> console.log(arguments) @@ -126,13 +127,13 @@ describe "UserEmailsController", -> @EmailHelper.parseEmail.returns @email it 'sets default email', (done) -> - @UserUpdater.setDefaultEmailAddress.callsArgWith 2, null + @UserUpdater.updateV1AndSetDefaultEmailAddress.callsArgWith 2, null @UserEmailsController.setDefault @req, sendStatus: (code) => code.should.equal 200 assertCalledWith @EmailHelper.parseEmail, @email - assertCalledWith @UserUpdater.setDefaultEmailAddress, @user._id, @email + assertCalledWith @UserUpdater.updateV1AndSetDefaultEmailAddress, @user._id, @email done() it 'handles email parse error', (done) -> diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee index 50ab2261f1..79d9032283 100644 --- a/services/web/test/unit/coffee/User/UserGetterTests.coffee +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -5,6 +5,7 @@ path = require('path') sinon = require('sinon') modulePath = path.join __dirname, "../../../../app/js/Features/User/UserGetter" expect = require("chai").expect +Errors = require "../../../../app/js/Features/Errors/Errors" describe "UserGetter", -> @@ -21,15 +22,16 @@ describe "UserGetter", -> db: users: findOne: @findOne ObjectId: (id) -> return id settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } } - @getAffiliations = sinon.stub().callsArgWith(1, null, []) + @getUserAffiliations = sinon.stub().callsArgWith(1, null, []) @UserGetter = SandboxedModule.require modulePath, requires: "logger-sharelatex": log:-> "../../infrastructure/mongojs": @Mongo "metrics-sharelatex": timeAsyncMethod: sinon.stub() 'settings-sharelatex': settings - './UserAffiliationsManager': - getAffiliations: @getAffiliations + '../Institutions/InstitutionsAPI': + getUserAffiliations: @getUserAffiliations + "../Errors/Errors": Errors describe "getUser", -> it "should get user", (done)-> @@ -75,7 +77,7 @@ describe "UserGetter", -> institution: { name: 'University Name', isUniversity: true } } ] - @getAffiliations.callsArgWith(1, null, affiliationsData) + @getUserAffiliations.callsArgWith(1, null, affiliationsData) @UserGetter.getUserFullEmails @fakeUser._id, (error, fullEmails) => assert.deepEqual fullEmails, [ { @@ -150,6 +152,7 @@ describe "UserGetter", -> @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @fakeUser) @UserGetter.ensureUniqueEmailAddress @newEmail, (err)=> should.exist(err) + expect(err).to.be.an.instanceof(Errors.EmailExistsError) err.message.should.equal 'alread_exists' done() diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee index e9a6c568dd..f8bcce30ce 100644 --- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee +++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee @@ -132,11 +132,17 @@ describe "UserRegistrationHandler", -> @AuthenticationManager.setUserPassword.calledWith(@user._id, @passingRequest.password).should.equal true done() - it "should add the user to the news letter manager", (done)-> + it "should add the user to the newsletter if accepted terms", (done)-> + @passingRequest.subscribeToNewsletter = "true" @handler.registerNewUser @passingRequest, (err)=> @NewsLetterManager.subscribe.calledWith(@user).should.equal true done() + it "should not add the user to the newsletter if not accepted terms", (done)-> + @handler.registerNewUser @passingRequest, (err)=> + @NewsLetterManager.subscribe.calledWith(@user).should.equal false + done() + it "should track the registration event", (done)-> @handler.registerNewUser @passingRequest, (err)=> @AnalyticsManager.recordEvent diff --git a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee index 3820e2a448..17f691edba 100644 --- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee +++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee @@ -18,17 +18,27 @@ describe "UserUpdater", -> getUserEmail: sinon.stub() getUserByAnyEmail: sinon.stub() ensureUniqueEmailAddress: sinon.stub() - @logger = err: sinon.stub(), log: -> + @logger = + err: sinon.stub() + log: -> + warn: -> @addAffiliation = sinon.stub().yields() @removeAffiliation = sinon.stub().callsArgWith(2, null) + @refreshFeatures = sinon.stub().yields() + @NewsletterManager = + changeEmail:sinon.stub() @UserUpdater = SandboxedModule.require modulePath, requires: "logger-sharelatex": @logger - "./UserGetter": @UserGetter - './UserAffiliationsManager': - addAffiliation: @addAffiliation - removeAffiliation: @removeAffiliation "../../infrastructure/mongojs":@mongojs "metrics-sharelatex": timeAsyncMethod: sinon.stub() + "./UserGetter": @UserGetter + '../Institutions/InstitutionsAPI': + addAffiliation: @addAffiliation + removeAffiliation: @removeAffiliation + '../Subscription/FeaturesUpdater': refreshFeatures: @refreshFeatures + "settings-sharelatex": @settings = {} + "request": @request = {} + "../Newsletter/NewsletterManager": @NewsletterManager @stubbedUser = _id: "3131231" @@ -170,6 +180,10 @@ describe "UserUpdater", -> done() describe 'setDefaultEmailAddress', -> + beforeEach -> + @UserGetter.getUserEmail.callsArgWith(1, null, @stubbedUser.email) + @NewsletterManager.changeEmail.callsArgWith(2, null) + it 'set default', (done)-> @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1) @@ -181,6 +195,16 @@ describe "UserUpdater", -> ).should.equal true done() + it 'set changed the email in newsletter', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1) + + @UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + @NewsletterManager.changeEmail.calledWith( + @stubbedUser.email, @newEmail + ).should.equal true + done() + it 'handle error', (done)-> @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope')) @@ -245,3 +269,9 @@ describe "UserUpdater", -> should.exist(err) @UserUpdater.updateUser.called.should.equal false done() + + it 'refresh features', (done)-> + @UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + sinon.assert.calledWith(@refreshFeatures, @stubbedUser._id, true) + done() diff --git a/services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee b/services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee index 5d5ba13609..2fc5c5b3b6 100644 --- a/services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee +++ b/services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee @@ -15,14 +15,18 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) -> nextBeforeTimestamp: null atEnd: false selection: { + label: null updates: [] - pathname: null docs: {} + pathname: null range: { fromV: null toV: null } } + error: null + showOnlyLabels: false + labels: null diff: null files: [] selectedFile: null