diff --git a/services/web/app/coffee/Features/Analytics/AnalyticsController.coffee b/services/web/app/coffee/Features/Analytics/AnalyticsController.coffee index 38029219ee..e407feb488 100644 --- a/services/web/app/coffee/Features/Analytics/AnalyticsController.coffee +++ b/services/web/app/coffee/Features/Analytics/AnalyticsController.coffee @@ -1,6 +1,7 @@ AnalyticsManager = require "./AnalyticsManager" Errors = require "../Errors/Errors" AuthenticationController = require("../Authentication/AuthenticationController") +InstitutionsAPI = require("../Institutions/InstitutionsAPI") GeoIpLookup = require '../../infrastructure/GeoIpLookup' module.exports = AnalyticsController = @@ -23,6 +24,14 @@ module.exports = AnalyticsController = AnalyticsManager.recordEvent user_id, req.params.event, req.body, (error) -> respondWith(error, res, next) + licences: (req, res, next) -> + {resource_id, start_date, end_date, lag} = req.query + InstitutionsAPI.getInstitutionLicences resource_id, start_date, end_date, lag, (error, licences) -> + if error? + next(error) + else + res.send licences + respondWith = (error, res, next) -> if error instanceof Errors.ServiceNotConfiguredError # ignore, no-op diff --git a/services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee b/services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee index 06ca2bfa1c..57b131326f 100644 --- a/services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee +++ b/services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee @@ -23,4 +23,4 @@ module.exports = publicApiRouter.use '/analytics/uniExternalCollaboration', AuthenticationController.httpAuth, - AnalyticsProxy.call('/uniExternalCollaboration') \ No newline at end of file + AnalyticsProxy.call('/uniExternalCollaboration') diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee index 0cea9d0970..8a2c33536a 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") @@ -64,7 +63,10 @@ module.exports = AuthenticationController = if user # `user` is either a user object or false 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) -> @@ -81,20 +83,30 @@ module.exports = AuthenticationController = 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 - 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, ()->) 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 9a4e61a063..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,6 +112,7 @@ 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 diff --git a/services/web/app/coffee/Features/Compile/CompileController.coffee b/services/web/app/coffee/Features/Compile/CompileController.coffee index d4a2ef161d..8e64e8a3cf 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/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/Institutions/InstitutionsAPI.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee index 8ce39d68e3..dfc04e63b1 100644 --- a/services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee +++ b/services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee @@ -9,15 +9,22 @@ module.exports = InstitutionsAPI = method: 'GET' path: "/api/v2/institutions/#{institutionId.toString()}/affiliations" defaultErrorMessage: "Couldn't get institution affiliations" - }, callback + }, (error, body) -> callback(error, body or []) + getInstitutionLicences: (institutionId, startDate, endDate, lag, callback = (error, body) ->) -> + makeAffiliationRequest { + method: 'GET' + path: "/api/v2/institutions/#{institutionId.toString()}/institution_licences" + body: {start_date: startDate, end_date: endDate, lag} + defaultErrorMessage: "Couldn't get institution licences" + }, callback getUserAffiliations: (userId, callback = (error, body) ->) -> makeAffiliationRequest { method: 'GET' path: "/api/v2/users/#{userId.toString()}/affiliations" defaultErrorMessage: "Couldn't get user affiliations" - }, callback + }, (error, body) -> callback(error, body or []) addAffiliation: (userId, email, affiliationOptions, callback) -> @@ -25,11 +32,11 @@ module.exports = InstitutionsAPI = callback = affiliationOptions affiliationOptions = {} - { university, department, role } = affiliationOptions + { university, department, role, confirmedAt } = affiliationOptions makeAffiliationRequest { method: 'POST' path: "/api/v2/users/#{userId.toString()}/affiliations" - body: { email, university, department, role } + body: { email, university, department, role, confirmedAt } defaultErrorMessage: "Couldn't create affiliation" }, callback @@ -80,6 +87,8 @@ 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) diff --git a/services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee index 5c91058146..4ce233406b 100644 --- a/services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee +++ b/services/web/app/coffee/Features/Institutions/InstitutionsFeatures.coffee @@ -1,4 +1,4 @@ -UserGetter = require '../User/UserGetter' +InstitutionsGetter = require './InstitutionsGetter' PlansLocator = require '../Subscription/PlansLocator' Settings = require 'settings-sharelatex' logger = require 'logger-sharelatex' @@ -13,11 +13,10 @@ module.exports = InstitutionsFeatures = hasLicence: (userId, callback = (error, hasLicence) ->) -> - UserGetter.getUserFullEmails userId, (error, emailsData) -> + InstitutionsGetter.getConfirmedInstitutions userId, (error, institutions) -> return callback error if error? - affiliation = emailsData.find (emailData) -> - licence = emailData.affiliation?.institution?.licence - emailData.confirmedAt? and licence? and licence != 'free' + hasLicence = institutions.some (institution) -> + institution.licence and institution.licence != 'free' - callback(null, !!affiliation) + 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/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 4bcaf8c6c8..fc96c9c21b 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -49,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 @@ -347,7 +351,7 @@ module.exports = ProjectController = useV2History: !!project.overleaf?.history?.display 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/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee index 2dfa7f5ac0..17248b38ae 100644 --- a/services/web/app/coffee/Features/User/UserCreator.coffee +++ b/services/web/app/coffee/Features/User/UserCreator.coffee @@ -6,15 +6,18 @@ metrics = require('metrics-sharelatex') 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/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 8d5e3658f9..3abffdf444 100644 --- a/services/web/app/coffee/Features/User/UserUpdater.coffee +++ b/services/web/app/coffee/Features/User/UserUpdater.coffee @@ -11,6 +11,7 @@ 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) ->) -> @@ -99,15 +100,21 @@ 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 + + updateV1AndSetDefaultEmailAddress: (userId, email, callback) -> @updateEmailAddressInV1 userId, email, (error) => @@ -152,11 +159,14 @@ module.exports = UserUpdater = else return callback new Error("non-success code from v1: #{response.statusCode}") - confirmEmail: (userId, email, callback) -> + 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' - addAffiliation userId, email, (error) => + addAffiliation userId, email, {confirmedAt: confirmedAt}, (error) => if error? logger.err error: error, 'problem adding affiliation while confirming email' return callback(error) @@ -166,7 +176,7 @@ 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" diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index 5c71afbe5d..d4444b3542 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -333,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 ffc60604e6..43dadcda2d 100644 --- a/services/web/app/coffee/infrastructure/Features.coffee +++ b/services/web/app/coffee/infrastructure/Features.coffee @@ -25,5 +25,7 @@ module.exports = Features = 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/router.coffee b/services/web/app/coffee/router.coffee index f35cd6c0ab..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") @@ -245,6 +245,7 @@ module.exports = class Router 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/faq_search.pug b/services/web/app/views/_mixins/faq_search.pug new file mode 100644 index 0000000000..b5d00f9832 --- /dev/null +++ b/services/web/app/views/_mixins/faq_search.pug @@ -0,0 +1,21 @@ +mixin faq_search(headerText, headerClass) + - if(typeof(settings.algolia) != "undefined" && typeof(settings.algolia.indexes) != "undefined" && typeof(settings.algolia.indexes.wiki) != "undefined") + if headerText + div(class=headerClass) #{headerText} + .wiki(ng-controller="SearchWikiController") + form.project-search.form-horizontal(role="form") + .form-group.has-feedback.has-feedback-left + .col-sm-12 + input.form-control(type='text', ng-model='searchQueryText', ng-keyup='search()', placeholder="Search help library....") + i.fa.fa-search.form-control-feedback-left + i.fa.fa-times.form-control-feedback( + ng-click="clearSearchText()", + style="cursor: pointer;", + ng-show="searchQueryText.length > 0" + ) + + .row + .col-md-12(ng-cloak) + a(ng-href='{{hit.url}}',ng-repeat='hit in hits').search-result.card.card-thin + span(ng-bind-html='hit.name') + div.search-result-content(ng-show="hit.content != ''", ng-bind-html='hit.content') 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 9e633667eb..15d3150035 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/mixins/_pagination.pug b/services/web/app/views/mixins/_pagination.pug new file mode 100644 index 0000000000..4ac2d924e8 --- /dev/null +++ b/services/web/app/views/mixins/_pagination.pug @@ -0,0 +1,74 @@ +mixin paginate(pages, page_path, max_btns) + //- @param pages.current_page the current page viewed + //- @param pages.total_pages previously calculated, + //- based on total entries and entries per page + //- @param page_path the relative path, minus a trailing slash and page param + //- @param max_btns max number of buttons on either side of the current page + //- button and excludes first, prev, next, last + + if pages && pages.current_page && pages.total_pages + - var max_btns = max_btns || 4 + - var prev_page = Math.max(parseInt(pages.current_page, 10) - max_btns, 1) + - var next_page = parseInt(pages.current_page, 10) + 1 + - var next_index = 0; + - var full_page_path = page_path + "/page/" + + nav(role="navigation" aria-label="Pagination Navigation") + ul.pagination + if pages.current_page > 1 + li + a( + aria-label="Go to first page" + href=page_path + ) « First + li + a( + aria-label="Go to previous page" + href=full_page_path + (parseInt(pages.current_page, 10) - 1) + rel="prev" + ) ‹ Prev + + if pages.current_page - max_btns > 1 + li + span … + + while prev_page < pages.current_page + li + a( + aria-label="Go to page " + prev_page + href=full_page_path + prev_page + ) #{prev_page} + - prev_page++ + + li(class="active") + span( + aria-label="Current Page, Page " + pages.current_page + aria-current="true" + ) #{pages.current_page} + + if pages.current_page < pages.total_pages + while next_page <= pages.total_pages && next_index < max_btns + li + a( + aria-label="Go to page " + next_page + href=full_page_path + next_page + ) #{next_page} + - next_page++ + - next_index++ + + if next_page <= pages.total_pages + li + span … + + li + a( + aria-label="Go to next page" + href=full_page_path + (parseInt(pages.current_page, 10) + 1) + rel="next" + ) Next › + + li + a( + aria-label="Go to last page" + href=full_page_path + pages.total_pages + ) Last » diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 6d7d18687d..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" diff --git a/services/web/app/views/project/editor/history.pug b/services/web/app/views/project/editor/history.pug index a7a52d2927..f968639cca 100644 --- a/services/web/app/views/project/editor/history.pug +++ b/services/web/app/views/project/editor/history.pug @@ -1,45 +1,4 @@ div#history(ng-show="ui.view == 'history'") - span - .upgrade-prompt(ng-if="project.features.versioning === false && ui.view === 'history'") - .message(ng-if="project.owner._id == user.id") - p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})} - p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")} - ul.list-unstyled - li - i.fa.fa-check   - | #{translate("unlimited_projects")} - - li - i.fa.fa-check   - | #{translate("collabs_per_proj", {collabcount:'Multiple'})} - - li - i.fa.fa-check   - | #{translate("full_doc_history")} - - li - i.fa.fa-check   - | #{translate("sync_to_dropbox")} - - li - i.fa.fa-check   - | #{translate("sync_to_github")} - - li - i.fa.fa-check   - |#{translate("compile_larger_projects")} - p.text-center(ng-controller="FreeTrialModalController") - a.btn.btn-success( - href - ng-class="buttonClass" - ng-click="startFreeTrial('history')" - ) #{translate("start_free_trial")} - - .message(ng-show="project.owner._id != user.id") - p #{translate("ask_proj_owner_to_upgrade_for_history")} - p - a.small(href, ng-click="toggleHistory()") #{translate("cancel")} - include ./history/entriesListV1 include ./history/entriesListV2 @@ -67,3 +26,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..640a761296 100644 --- a/services/web/app/views/project/editor/history/entriesListV2.pug +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -3,13 +3,28 @@ aside.change-list( ng-controller="HistoryV2ListController" ) history-entries-list( + ng-if="!history.showOnlyLabels && !history.error" entries="history.updates" current-user="user" + current-user-is-owner="project.owner._id === user.id" + users="projectUsers" load-entries="loadMore()" load-disabled="history.loading || history.atEnd" load-initialize="ui.view == 'history'" is-loading="history.loading" + free-history-limit-hit="history.freeHistoryLimitHit" 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 +80,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,12 +129,55 @@ 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 |    #{translate("loading")}... + .history-entries-list-upgrade-prompt( + ng-if="$ctrl.freeHistoryLimitHit && $ctrl.currentUserIsOwner" + ng-controller="FreeTrialModalController" + ) + p #{translate("currently_seeing_only_24_hrs_history")} + p: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})} + ul.list-unstyled + li + i.fa.fa-check   + | #{translate("unlimited_projects")} + + li + i.fa.fa-check   + | #{translate("collabs_per_proj", {collabcount:'Multiple'})} + + li + i.fa.fa-check   + | #{translate("full_doc_history")} + + li + i.fa.fa-check   + | #{translate("sync_to_dropbox")} + + li + i.fa.fa-check   + | #{translate("sync_to_github")} + + li + i.fa.fa-check   + |#{translate("compile_larger_projects")} + p.text-center + a.btn.btn-success( + href + ng-class="buttonClass" + ng-click="startFreeTrial('history')" + ) #{translate("start_free_trial")} + p.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")} + .history-entries-list-upgrade-prompt( + ng-if="$ctrl.freeHistoryLimitHit && !$ctrl.currentUserIsOwner" + ) + p #{translate("currently_seeing_only_24_hrs_history")} + strong #{translate("ask_proj_owner_to_upgrade_for_full_history")} script(type="text/ng-template", id="historyEntryTpl") .history-entry( @@ -129,6 +195,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 +223,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 +247,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..2acb93854b 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.selection.updates.length && !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 || history.selection.updates.length == 0" + ) + i.fa.fa-tag + |  #{translate("history_label_this_version")} + button.history-toolbar-btn( + ng-click="toggleHistoryViewMode();" + ng-disabled="history.loadingFileTree || history.selection.updates.length == 0" + ) + 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/pdf.pug b/services/web/app/views/project/editor/pdf.pug index 26de2f35b1..d485d24700 100644 --- a/services/web/app/views/project/editor/pdf.pug +++ b/services/web/app/views/project/editor/pdf.pug @@ -305,7 +305,7 @@ div.full-size.pdf(ng-controller="PdfController") dbl-click-callback="syncToCode" ) iframe( - ng-src="{{ pdf.url }}" + ng-src="{{ pdf.url | trusted }}" ng-if="settings.pdfViewer == 'native'" ) 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 a43e81c12c..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 && hasFeature('affiliations') + if hasFeature('affiliations') include settings/user-affiliations form-messages(for="settingsForm") @@ -22,7 +22,7 @@ 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 !(locals.showAffiliationsUI && hasFeature('affiliations')) + if !hasFeature('affiliations') if !externalAuthenticationSystemUsed() .form-group label(for='email') #{translate("email")} @@ -167,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/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 1306ce91c2..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 @@ -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/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/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/directives/aceEditor/auto-complete/AutoCompleteManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee index d3bde5bed5..7fb231d558 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,7 @@ define [ cursorPosition = @editor.getCursorPosition() end = change.end {lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end) - if /.*((?![\\]).|^)%.*/.test(lineUpToCursor) + if ((i = lineUpToCursor.indexOf('%')) > -1 and lineUpToCursor[i-1] != '\\') return lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\" lastTwoChars = lineUpToCursor.slice(-2) diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index 7c8f476e39..66b4375c8e 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() @@ -59,7 +82,10 @@ define [ viewMode: null nextBeforeTimestamp: null atEnd: false + userHasFullFeature: @$scope.project?.features?.versioning or false + freeHistoryLimitHit: false selection: { + label: null updates: [] docs: {} pathname: null @@ -68,6 +94,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 +110,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 +141,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 +154,109 @@ 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? or @$scope.history.freeHistoryLimitHit @$scope.history.atEnd = true @$scope.history.loading = false + if @$scope.history.updates.length == 0 + @$scope.history.loadingFileTree = 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 +312,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 } @@ -252,23 +391,34 @@ define [ _loadUpdates: (updates = []) -> previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1] - - for update in updates or [] + dateTimeNow = new Date() + timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1) + cutOffIndex = null + + for update, i in updates or [] for user in update.meta.users or [] if user? user.hue = ColorManager.getHueForUserId(user.id) if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day") update.meta.first_in_day = true - + update.selectedFrom = false update.selectedTo = false update.inSelection = false previousUpdate = update + if !@$scope.history.userHasFullFeature and update.meta.end_ts < timestamp24hoursAgo + cutOffIndex = i or 1 # Make sure that we show at least one entry (to allow labelling). + @$scope.history.freeHistoryLimitHit = true + break + firstLoad = @$scope.history.updates.length == 0 + if !@$scope.history.userHasFullFeature and cutOffIndex? + updates = updates.slice 0, cutOffIndex + @$scope.history.updates = @$scope.history.updates.concat(updates) @@ -276,7 +426,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..7e97121868 100644 --- a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee +++ b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee @@ -3,17 +3,37 @@ 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: "<" + freeHistoryLimitHit: "<" + currentUserIsOwner: "<" 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 c6b935a595..ede89d39a2 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -12,6 +12,10 @@ define [ # and then again on ack. AUTO_COMPILE_DEBOUNCE = 2000 + App.filter('trusted', ['$sce', ($sce)-> + return (url)-> return $sce.trustAsResourceUrl(url); + ]) + App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, logHintsFeedback, localStorage) -> # enable per-user containers by default perUserCompile = true @@ -224,6 +228,13 @@ define [ _csrf: window.csrfToken }, {params: params} + buildPdfDownloadUrl = (pdfDownloadDomain, path)-> + #we only download builds from compiles server for security reasons + if pdfDownloadDomain? and path? and path.indexOf("build") != -1 + return "#{pdfDownloadDomain}#{path}" + else + return path + parseCompileResponse = (response) -> # keep last url @@ -244,11 +255,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 +274,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 +307,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 +327,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 +342,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 +391,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 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/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/factories/UserAffiliationsDataService.coffee b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee index cbcadf7e67..ca1ffef168 100644 --- a/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee +++ b/services/web/public/coffee/main/affiliations/factories/UserAffiliationsDataService.coffee @@ -31,6 +31,10 @@ define [ $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] @@ -118,6 +122,7 @@ define [ getDefaultRoleHints getDefaultDepartmentHints getUserEmails + getUserDefaultEmail getUniversitiesFromCountry getUniversityDomainFromPartialDomainInput getUniversityDetails 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/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less index 9089ed1ae9..35190cd662 100644 --- a/services/web/public/stylesheets/app/editor/history-v2.less +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -15,16 +15,31 @@ .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; + margin-right: (@line-height-computed / 2); } - .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-right: (@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 +63,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 +148,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 +174,25 @@ } } +.history-entries-list-upgrade-prompt { + background-color: #FFF; + margin-bottom: 2px; + padding: 5px 10px; +} + +.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 +269,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/review-panel.less b/services/web/public/stylesheets/app/editor/review-panel.less index 800a25618f..a7e7d3fdbd 100644 --- a/services/web/public/stylesheets/app/editor/review-panel.less +++ b/services/web/public/stylesheets/app/editor/review-panel.less @@ -922,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; @@ -945,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'); } } diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 4006b56a7b..c5499180e0 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/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/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/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index fefe12da73..9112c1400c 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; @@ -986,14 +989,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 0e05ccdd68..7c22b1f01c 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; @@ -167,6 +176,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; @@ -265,6 +277,21 @@ @chat-new-message-textarea-bg : @ol-blue-gray-1; @chat-new-message-textarea-color : @ol-blue-gray-6; +// Pagination +@pagination-active-bg : @ol-dark-green; +@pagination-active-border : @gray-lighter; +@pagination-active-color : #FFF; +@pagination-bg : #FFF; +@pagination-border : @gray-lighter; +@pagination-color : @ol-dark-green; +@pagination-disabled-color : @gray-dark; +@pagination-disabled-bg : @gray-lightest; +@pagination-disabled-border : @gray-lighter; +@pagination-hover-color : @ol-dark-green; +@pagination-hover-bg : @gray-lightest; +@pagination-hover-border : @gray-lighter; + + // PDF @pdf-top-offset : @toolbar-small-height; @pdf-bg : @ol-blue-gray-1; @@ -273,16 +300,29 @@ @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; @@ -300,6 +340,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..2be148a497 100644 --- a/services/web/public/stylesheets/ol-style.less +++ b/services/web/public/stylesheets/ol-style.less @@ -6,8 +6,12 @@ @import "app/ol-style-guide.less"; @import "_style_includes.less"; @import "_ol_style_includes.less"; +@import "components/embed-responsive.less"; +@import "components/icons.less"; +@import "components/pagination.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/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee b/services/web/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee index c3e3802f37..0aa0517074 100644 --- a/services/web/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee +++ b/services/web/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee @@ -17,9 +17,13 @@ describe 'AnalyticsController', -> updateEditingSession: sinon.stub().callsArgWith(3) recordEvent: sinon.stub().callsArgWith(3) + @InstitutionsAPI = + getInstitutionLicences: sinon.stub().callsArgWith(4) + @controller = SandboxedModule.require modulePath, requires: "./AnalyticsManager":@AnalyticsManager "../Authentication/AuthenticationController":@AuthenticationController + "../Institutions/InstitutionsAPI":@InstitutionsAPI "logger-sharelatex": log:-> '../../infrastructure/GeoIpLookup': @GeoIpLookup = @@ -66,3 +70,19 @@ describe 'AnalyticsController', -> @controller.recordEvent @req, @res @AnalyticsManager.recordEvent.calledWith(@req.sessionID, @req.params["event"], @req.body).should.equal true done() + + describe "licences", -> + beforeEach -> + @req = + query: + resource_id:1 + start_date:'1514764800' + end_date:'1530662400' + resource_type:'institution' + sessionID: "sessionIDHere" + session: {} + + it "should trigger institutions api to fetch licences graph data", (done)-> + @controller.licences @req, @res + @InstitutionsAPI.getInstitutionLicences.calledWith(@req.query["resource_id"], @req.query["start_date"], @req.query["end_date"], @req.query["lag"]).should.equal true + done() diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee index 8482c28e04..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" @@ -214,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 @@ -222,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 -> 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/Institutions/InstitutionsAPITests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee index 7500e46e40..0c4e7ae445 100644 --- a/services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee +++ b/services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee @@ -11,12 +11,12 @@ describe "InstitutionsAPI", -> beforeEach -> @logger = err: sinon.stub(), log: -> - settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } } + @settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } } @request = sinon.stub() @InstitutionsAPI = SandboxedModule.require modulePath, requires: "logger-sharelatex": @logger "metrics-sharelatex": timeAsyncMethod: sinon.stub() - 'settings-sharelatex': settings + 'settings-sharelatex': @settings 'request': @request @stubbedUser = @@ -40,6 +40,34 @@ describe "InstitutionsAPI", -> should.not.exist(requestOptions.body) body.should.equal responseBody done() + + it 'handle empty response', (done)-> + @settings.apis = null + @InstitutionsAPI.getInstitutionAffiliations @institutionId, (err, body) => + should.not.exist(err) + expect(body).to.be.a 'Array' + body.length.should.equal 0 + done() + + describe 'getInstitutionLicences', -> + it 'get licences', (done)-> + @institutionId = 123 + responseBody = {"lag":"monthly","data":[{"key":"users","values":[{"x":"2018-01-01","y":1}]}]} + @request.yields(null, { statusCode: 200 }, responseBody) + startDate = '1417392000' + endDate = '1420848000' + @InstitutionsAPI.getInstitutionLicences @institutionId, startDate, endDate, 'monthly', (err, body) => + should.not.exist(err) + @request.calledOnce.should.equal true + requestOptions = @request.lastCall.args[0] + expectedUrl = "v1.url/api/v2/institutions/#{@institutionId}/institution_licences" + requestOptions.url.should.equal expectedUrl + requestOptions.method.should.equal 'GET' + requestOptions.body['start_date'].should.equal startDate + requestOptions.body['end_date'].should.equal endDate + requestOptions.body.lag.should.equal 'monthly' + body.should.equal responseBody + done() describe 'getUserAffiliations', -> it 'get affiliations', (done)-> @@ -65,6 +93,14 @@ describe "InstitutionsAPI", -> err.message.should.have.string body.errors done() + it 'handle empty response', (done)-> + @settings.apis = null + @InstitutionsAPI.getUserAffiliations @stubbedUser._id, (err, body) => + should.not.exist(err) + expect(body).to.be.a 'Array' + body.length.should.equal 0 + done() + describe 'addAffiliation', -> beforeEach -> @request.callsArgWith(1, null, { statusCode: 201 }) @@ -74,6 +110,7 @@ describe "InstitutionsAPI", -> university: { id: 1 } role: 'Prof' department: 'Math' + confirmedAt: new Date() @InstitutionsAPI.addAffiliation @stubbedUser._id, @newEmail, affiliationOptions, (err)=> should.not.exist(err) @request.calledOnce.should.equal true @@ -83,11 +120,12 @@ describe "InstitutionsAPI", -> requestOptions.method.should.equal 'POST' body = requestOptions.body - Object.keys(body).length.should.equal 4 + Object.keys(body).length.should.equal 5 body.email.should.equal @newEmail body.university.should.equal affiliationOptions.university body.department.should.equal affiliationOptions.department body.role.should.equal affiliationOptions.role + body.confirmedAt.should.equal affiliationOptions.confirmedAt done() it 'handle error', (done)-> diff --git a/services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee index 2304f2e5b7..8811f72c35 100644 --- a/services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee +++ b/services/web/test/unit/coffee/Institutions/InstitutionsFeaturesTests.coffee @@ -8,11 +8,11 @@ modulePath = require('path').join __dirname, '../../../../app/js/Features/Instit describe 'InstitutionsFeatures', -> beforeEach -> - @UserGetter = getUserFullEmails: sinon.stub() + @InstitutionsGetter = getConfirmedInstitutions: sinon.stub() @PlansLocator = findLocalPlanInSettings: sinon.stub() @institutionPlanCode = 'institution_plan_code' @InstitutionsFeatures = SandboxedModule.require modulePath, requires: - '../User/UserGetter': @UserGetter + './InstitutionsGetter': @InstitutionsGetter '../Subscription/PlansLocator': @PlansLocator 'settings-sharelatex': institutionPlanCode: @institutionPlanCode 'logger-sharelatex': @@ -23,47 +23,37 @@ describe 'InstitutionsFeatures', -> describe "hasLicence", -> it 'should handle error', (done)-> - @UserGetter.getUserFullEmails.yields(new Error('Nope')) + @InstitutionsGetter.getConfirmedInstitutions.yields(new Error('Nope')) @InstitutionsFeatures.hasLicence @userId, (error, hasLicence) -> expect(error).to.exist done() - it 'should return false if user has no affiliations', (done) -> - @UserGetter.getUserFullEmails.yields(null, []) - @InstitutionsFeatures.hasLicence @userId, (error, hasLicence) -> - expect(error).to.not.exist - expect(hasLicence).to.be.false - done() - it 'should return false if user has no confirmed affiliations', (done) -> - affiliations = [ - { confirmedAt: null, affiliation: institution: { licence: 'pro_plus' } } - ] - @UserGetter.getUserFullEmails.yields(null, affiliations) + 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) -> - affiliations = [ - { confirmedAt: new Date(), affiliation: institution: { licence: 'free' } } + institutions = [ + { licence: 'free' } ] - @UserGetter.getUserFullEmails.yields(null, affiliations) + @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)-> - affiliations = [ - { confirmedAt: new Date(), affiliation: institution: { licence: 'pro_plus' } } - { confirmedAt: new Date(), affiliation: institution: { licence: 'free' } } - { confirmedAt: null, affiliation: institution: { licence: 'pro' } } - { confirmedAt: null, affiliation: institution: { licence: null } } - { confirmedAt: new Date(), affiliation: institution: {} } + institutions = [ + { licence: 'pro_plus' } + { licence: 'free' } + { licence: 'pro' } + { licence: null } ] - @UserGetter.getUserFullEmails.yields(null, affiliations) + @InstitutionsGetter.getConfirmedInstitutions.yields(null, institutions) @InstitutionsFeatures.hasLicence @userId, (error, hasLicence) -> expect(error).to.not.exist expect(hasLicence).to.be.true 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/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index 1acd1c8128..46e54501c9 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -145,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/User/UserCreatorTests.coffee b/services/web/test/unit/coffee/User/UserCreatorTests.coffee index f9d88e4cf8..d9118e450f 100644 --- a/services/web/test/unit/coffee/User/UserCreatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserCreatorTests.coffee @@ -20,7 +20,7 @@ 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: ()->} "../Institutions/InstitutionsAPI": addAffiliation: @addAffiliation @@ -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/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 f2ac951727..3be7733153 100644 --- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee +++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee @@ -18,21 +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 + "../../infrastructure/mongojs":@mongojs + "metrics-sharelatex": timeAsyncMethod: sinon.stub() "./UserGetter": @UserGetter '../Institutions/InstitutionsAPI': addAffiliation: @addAffiliation removeAffiliation: @removeAffiliation '../Subscription/FeaturesUpdater': refreshFeatures: @refreshFeatures - "../../infrastructure/mongojs":@mongojs - "metrics-sharelatex": timeAsyncMethod: sinon.stub() "settings-sharelatex": @settings = {} "request": @request = {} + "../Newsletter/NewsletterManager": @NewsletterManager @stubbedUser = _id: "3131231" @@ -174,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) @@ -185,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')) @@ -221,7 +241,7 @@ describe "UserUpdater", -> @UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=> should.not.exist(err) @addAffiliation.calledOnce.should.equal true - sinon.assert.calledWith(@addAffiliation, @stubbedUser._id, @newEmail) + sinon.assert.calledWith(@addAffiliation, @stubbedUser._id, @newEmail, { confirmedAt: new Date() } ) done() it 'handle error', (done)-> @@ -244,7 +264,7 @@ describe "UserUpdater", -> done() it 'handle affiliation error', (done)-> - @addAffiliation.callsArgWith(2, new Error('nope')) + @addAffiliation.callsArgWith(3, new Error('nope')) @UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=> should.exist(err) @UserUpdater.updateUser.called.should.equal false 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..542ee3e8eb 100644 --- a/services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee +++ b/services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee @@ -4,30 +4,45 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) -> @scope = $watch: sinon.stub() $on: sinon.stub() + project: + features: + versioning: true @ide = {} @historyManager = new HistoryV2Manager(@ide, @scope) - it "should setup the history scope on intialization", -> + it "should setup the history scope on initialization", -> expect(@scope.history).to.deep.equal({ isV2: true updates: [] viewMode: null nextBeforeTimestamp: null atEnd: false + userHasFullFeature: true + freeHistoryLimitHit: 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 }) + + it "should setup history without full access to the feature if the project does not have versioning", -> + @scope.project.features.versioning = false + @historyManager = new HistoryV2Manager(@ide, @scope) + expect(@scope.history.userHasFullFeature).to.equal false + describe "_perDocSummaryOfUpdates", -> it "should return the range of updates for the docs", -> result = @historyManager._perDocSummaryOfUpdates([{