Răsfoiți Sursa

Merge branch 'master' into ja-review-panel

Conflicts:
	public/coffee/ide.coffee
	public/stylesheets/app/editor.less
James Allen 10 ani în urmă
părinte
comite
091eb7e462
27 a modificat fișierele cu 459 adăugiri și 211 ștergeri
  1. 22 16
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  2. 1 0
      services/web/app/coffee/Features/User/UserCreator.coffee
  3. 4 0
      services/web/app/coffee/infrastructure/Server.coffee
  4. 1 1
      services/web/app/coffee/models/User.coffee
  5. 4 6
      services/web/app/views/project/editor.jade
  6. 54 0
      services/web/app/views/project/editor/feature-onboarding.jade
  7. 65 70
      services/web/app/views/project/editor/history.jade
  8. 7 8
      services/web/app/views/project/editor/left-menu.jade
  9. 9 44
      services/web/app/views/project/list/side-bar.jade
  10. 13 24
      services/web/app/views/subscriptions/dashboard.jade
  11. 1 2
      services/web/app/views/subscriptions/plans.jade
  12. 2 7
      services/web/app/views/subscriptions/successful_subscription.jade
  13. 1 0
      services/web/package.json
  14. 2 0
      services/web/public/coffee/ide.coffee
  15. 35 0
      services/web/public/coffee/ide/FeatureOnboardingController.coffee
  16. 84 22
      services/web/public/coffee/ide/connection/ConnectionManager.coffee
  17. 1 1
      services/web/public/coffee/ide/editor/directives/aceEditor/undo/UndoManager.coffee
  18. 15 0
      services/web/public/coffee/main/plans.coffee
  19. 2 6
      services/web/public/coffee/main/subscription-dashboard.coffee
  20. BIN
      services/web/public/img/teasers/code-checker/code-checker.gif
  21. BIN
      services/web/public/img/teasers/code-checker/code-checker.mp4
  22. 1 0
      services/web/public/stylesheets/app/editor.less
  23. 78 0
      services/web/public/stylesheets/app/editor/feature-onboarding.less
  24. 3 3
      services/web/public/stylesheets/app/editor/left-menu.less
  25. 3 0
      services/web/public/stylesheets/core/variables.less
  26. 50 0
      services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
  27. 1 1
      services/web/test/UnitTests/coffee/User/UserCreatorTests.coffee

+ 22 - 16
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -30,6 +30,24 @@ module.exports = AuthenticationController =
 	deserializeUser: (user, cb) ->
 		cb(null, user)
 
+	afterLoginSessionSetup: (req, user, callback=(err)->) ->
+		req.login user, (err) ->
+			# Regenerate the session to get a new sessionID (cookie value) to
+			# protect against session fixation attacks
+			oldSession = req.session
+			req.session.destroy()
+			req.sessionStore.generate(req)
+			for key, value of oldSession
+				req.session[key] = value
+			# copy to the old `session.user` location, for backward-comptability
+			req.session.user = req.session.passport.user
+			req.session.save (err) ->
+				if err?
+					logger.err {user_id: user._id}, "error saving regenerated session after login"
+					return callback(err)
+				UserSessionsManager.trackSession(user, req.sessionID, () ->)
+				callback(null)
+
 	passportLogin: (req, res, next) ->
 		# This function is middleware which wraps the passport.authenticate middleware,
 		# so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
@@ -38,22 +56,10 @@ module.exports = AuthenticationController =
 			if err?
 				return next(err)
 			if user # `user` is either a user object or false
-				req.login user, (err) ->
-					# Regenerate the session to get a new sessionID (cookie value) to
-					# protect against session fixation attacks
-					oldSession = req.session
-					req.session.destroy()
-					req.sessionStore.generate(req)
-					for key, value of oldSession
-						req.session[key] = value
-					# copy to the old `session.user` location, for backward-comptability
-					req.session.user = req.session.passport.user
-					req.session.save (err) ->
-						if err?
-							logger.err {user_id: user._id}, "error saving regenerated session after login"
-							return next(err)
-						UserSessionsManager.trackSession(user, req.sessionID, () ->)
-						res.json {redir: req._redir}
+				AuthenticationController.afterLoginSessionSetup req, user, (err) ->
+					if err?
+						return next(err)
+					res.json {redir: req._redir}
 			else
 				res.json message: info
 		)(req, res, next)

+ 1 - 0
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -17,6 +17,7 @@ module.exports =
 		user = new User()
 		user.email = opts.email
 		user.holdingAccount = opts.holdingAccount
+		user.ace.syntaxValidation = true
 
 		username = opts.email.match(/^[^@]*/)
 		if opts.first_name? and opts.first_name.length != 0

+ 4 - 0
services/web/app/coffee/infrastructure/Server.coffee

@@ -106,6 +106,10 @@ passport.use(new LocalStrategy(
 passport.serializeUser(AuthenticationController.serializeUser)
 passport.deserializeUser(AuthenticationController.deserializeUser)
 
+Modules.hooks.fire 'passportSetup', passport, (err) ->
+	if err?
+		logger.err {err}, "error setting up passport in modules"
+
 # Measure expiry from last request, not last login
 webRouter.use (req, res, next) ->
 	req.session.touch()

+ 1 - 1
services/web/app/coffee/models/User.coffee

@@ -26,7 +26,7 @@ UserSchema = new Schema
 							autoComplete:   {type : Boolean, default: true}
 							spellCheckLanguage :   {type : String, default: "en"}
 							pdfViewer   :   {type : String, default: "pdfjs"}
-							syntaxValidation   :   {type : Boolean, default: true}
+							syntaxValidation   :   {type : Boolean}
 						}
 	features		  : {
 							collaborators: { type:Number,  default: Settings.defaultFeatures.collaborators }

+ 4 - 6
services/web/app/views/project/editor.jade

@@ -15,7 +15,8 @@ block content
 				p.text-center.text-danger(ng-if="state.error").ng-cloak 
 					span(ng-bind-html="state.error")
 
-
+		include ./editor/feature-onboarding
+		
 		.global-alerts(ng-cloak)
 			.alert.alert-danger.small(ng-if="connection.forced_disconnect")
 				strong #{translate("disconnected")}
@@ -88,7 +89,8 @@ block content
 block requirejs
 	script(type="text/javascript" src='/socket.io/socket.io.js')
 
-	//- don't use cdn for worker
+	//- don't use cdn for workers
+	- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false})
 	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false})
 
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
@@ -132,10 +134,6 @@ block requirejs
 			}
 		};
 		window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
-
-	- var aceWorkerPath = user.betaProgram ? buildJsPath(lib('ace'), {cdn:false,fingerprint:false}) : "" // don't use cdn for worker
-
-	script(type='text/javascript').
 		window.aceWorkerPath = "#{aceWorkerPath}";
 
 	script(

+ 54 - 0
services/web/app/views/project/editor/feature-onboarding.jade

@@ -0,0 +1,54 @@
+.feat-onboard(
+	ng-controller="FeatureOnboardingController"
+	ng-class="('feat-onboard-step' + innerStep)"
+	ng-if="!state.loading && ui.showCodeCheckerOnboarding"
+	ng-cloak
+)
+	.feat-onboard-wrapper
+		h1.feat-onboard-title
+			| Introducing&nbsp;
+			span.feat-onboard-title-name Code check
+		div(ng-if="innerStep === 1;")
+			p.feat-onboard-description
+				span.feat-onboard-description-name Code check&nbsp;
+				| will highlight potential problems in your LaTeX code, allowing you to handle errors earlier and become more productive.
+			.row
+				video.feat-onboard-video(autoplay, loop)
+					source(src="/img/teasers/code-checker/code-checker.mp4", type="video/mp4")
+					img(src="/img/teasers/code-checker/code-checker.gif")
+			.row.feat-onboard-adv-wrapper
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Missing&nbsp;
+						span.feat-onboard-adv-title-highlight brackets
+					p Forgot to place a closing bracket? We'll warn you.
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Unclosed&nbsp;
+						span.feat-onboard-adv-title-highlight environments
+					p 
+						| Know when you are missing an&nbsp;
+						code \end{...}
+						| &nbsp;command.
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Incorrect&nbsp;
+						span.feat-onboard-adv-title-highlight nesting
+					p 
+						| Order matters. Get notified when you use an&nbsp;
+						code \end{...}
+						| &nbsp; too soon.
+			.feat-onboard-btn-wrapper
+				button.btn.btn-primary(ng-click="turnCodeCheckOn();") Yes, turn Code check on
+			.feat-onboard-btn-wrapper
+				button.btn.btn-default(ng-click="turnCodeCheckOff();") No, disable it for now
+		div(ng-if="innerStep === 2;")
+			p.feat-onboard-description
+				| Remember: you can always turn&nbsp;
+				span.feat-onboard-description-name Code check&nbsp;
+				em on&nbsp;
+				| or&nbsp;
+				em off&nbsp;
+				|, in the settings menu.
+			.feat-onboard-btn-wrapper
+				button.btn.btn-primary(ng-click="dismiss();") OK, got it

+ 65 - 70
services/web/app/views/project/editor/history.jade

@@ -2,84 +2,79 @@ div#history(ng-show="ui.view == 'history'")
 	span(ng-controller="HistoryPremiumPopup")
 		.upgrade-prompt(ng-if="project.features.versioning === false && ui.view === 'history'")
 			
-			div(sixpack-switch="teaser-history")
-				.message(
-					sixpack-default
-					ng-show="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 &nbsp;
-							| #{translate("unlimited_projects")}
-						
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-						
-						li 
-							i.fa.fa-check &nbsp;
-							| #{translate("full_doc_history")}
-						
-						li 
-							i.fa.fa-check &nbsp;
-							| #{translate("sync_to_dropbox")}
-
-						li 
-							i.fa.fa-check &nbsp;
-							| #{translate("sync_to_github")}
-
-						li  
-							i.fa.fa-check &nbsp;
-							|#{translate("compile_larger_projects")}
-					p.text-center(ng-controller="FreeTrialModalController")
-						a.btn.btn-success(
-							href
-							ng-class="buttonClass"
-							ng-click="startFreeTrial('history')"
-							sixpack-convert="teaser-history"
-						) #{translate("start_free_trial")}
+			div(ng-if="project.owner._id == user.id")
+				div(sixpack-switch="teaser-history")
+					.message(sixpack-default)
+						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 &nbsp;
+								| #{translate("unlimited_projects")}
+							
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+							
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("full_doc_history")}
+							
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_dropbox")}
 
-				.message.message-wider(
-					sixpack-when="focused"
-					ng-show="project.owner._id == user.id"
-				)
-					header.message-header
-						h3 History
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_github")}
 
-					.message-body
-						h4.teaser-title See who changed what. Go back to previous versions.
-						img.teaser-img(
-							src="/img/teasers/history/teaser-history.png"
-							alt="History"
-						)
-						p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-						.row
-							.col-md-8.col-md-offset-2
-								ul.list-unstyled
-									li 
-										i.fa.fa-check &nbsp;
-										| Catch up with your collaborators changes
-									
-									li
-										i.fa.fa-check &nbsp;
-										| See changes over any time period
-									
-									li 
-										i.fa.fa-check &nbsp;
-										| Revert your documents to previous versions
-									
-									li 
-										i.fa.fa-check &nbsp;
-										| Restore deleted files
+							li  
+								i.fa.fa-check &nbsp;
+								|#{translate("compile_larger_projects")}
 						p.text-center(ng-controller="FreeTrialModalController")
 							a.btn.btn-success(
 								href
 								ng-class="buttonClass"
 								ng-click="startFreeTrial('history')"
 								sixpack-convert="teaser-history"
-							) Try it for free
+							) #{translate("start_free_trial")}
+
+					.message.message-wider(sixpack-when="focused")
+						header.message-header
+							h3 History
+
+						.message-body
+							h4.teaser-title See who changed what. Go back to previous versions.
+							img.teaser-img(
+								src="/img/teasers/history/teaser-history.png"
+								alt="History"
+							)
+							p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+							.row
+								.col-md-8.col-md-offset-2
+									ul.list-unstyled
+										li 
+											i.fa.fa-check &nbsp;
+											| Catch up with your collaborators changes
+										
+										li
+											i.fa.fa-check &nbsp;
+											| See changes over any time period
+										
+										li 
+											i.fa.fa-check &nbsp;
+											| Revert your documents to previous versions
+										
+										li 
+											i.fa.fa-check &nbsp;
+											| Restore deleted files
+							p.text-center(ng-controller="FreeTrialModalController")
+								a.btn.btn-success(
+									href
+									ng-class="buttonClass"
+									ng-click="startFreeTrial('history')"
+									sixpack-convert="teaser-history"
+								) Try it for free
 
 			.message(ng-show="project.owner._id != user.id")
 				p #{translate("ask_proj_owner_to_upgrade_for_history")}

+ 7 - 8
services/web/app/views/project/editor/left-menu.jade

@@ -105,14 +105,13 @@ aside#left-menu.full-size(
 					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
 				)
 
-			if (user.betaProgram)
-				.form-controls
-					label(for="syntaxValidation") #{translate("syntax_validation")}
-					select(
-						name="syntaxValidation"
-						ng-model="settings.syntaxValidation"
-						ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
-					)
+			.form-controls.code-check-setting
+				label(for="syntaxValidation") #{translate("syntax_validation")}
+				select(
+					name="syntaxValidation"
+					ng-model="settings.syntaxValidation"
+					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
+				)
 
 			.form-controls
 				label(for="theme") #{translate("theme")}

+ 9 - 44
services/web/app/views/project/list/side-bar.jade

@@ -109,51 +109,16 @@
 					) #{translate("complete")}
 
 
-		.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
-			span(sixpack-default).text-centered
-				hr
-				p.small #{translate("on_free_sl")}
-				p
-					a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-				p.small.text-centered
-					| #{translate("or_unlock_features_bonus")} 
-					a(href="/user/bonus") #{translate("sharing_sl")} .
+		.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak).text-centered
+			hr
+			p.small #{translate("on_free_sl")}
+			p
+				a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
+			p.small.text-centered
+				| #{translate("or_unlock_features_bonus")} 
+				a(href="/user/bonus") #{translate("sharing_sl")} .
 
-			span(sixpack-when="random").text-centered
-				span(ng-if="randomView == 'default'")
-					hr
-					p.small #{translate("on_free_sl")}
-					p
-						a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-					p.small.text-centered
-						| #{translate("or_unlock_features_bonus")} 
-						a(href="/user/bonus") #{translate("sharing_sl")} .
-
-				span(ng-if="randomView == 'dropbox'")
-					hr
-					.card.card-thin
-						p
-							span Get Dropbox Sync
-						p
-							img(src=buildImgPath("dropbox/simple_logo.png"))
-						p
-							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-						p.small.text-centered
-							| #{translate("or_unlock_features_bonus")} 
-							a(href="/user/bonus") #{translate("sharing_sl")} .
-
-				span(ng-if="randomView == 'github'")
-					hr
-					.card.card-thin
-						p
-							span Get Github Sync
-						p
-							img(src=buildImgPath("github/octocat.jpg"))
-						p
-							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-						p.small.text-centered
-							| #{translate("or_unlock_features_bonus")} 
-							a(href="/user/bonus") #{translate("sharing_sl")} .
+			
 script.
 	window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}
 

+ 13 - 24
services/web/app/views/subscriptions/dashboard.jade

@@ -113,39 +113,28 @@ block content
 							div
 								a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
 
-
-
 					.card(ng-if="view == 'cancelation'")
 						.page-header
 							h1 #{translate("Cancel Subscription")}
 
-						span(ng-if="sixpackOpt == 'downgrade-options'")
+						div(ng-show="showExtendFreeTrial", style="text-align: center")
+							p !{translate("have_more_days_to_try", {days:14})}
+							button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
+							p
+								| &nbsp;
+							p
+								a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
 
-							div(ng-show="showExtendFreeTrial", style="text-align: center")
-								p !{translate("have_more_days_to_try", {days:14})}
-								button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
+						div(ng-show="showDowngradeToStudent", style="text-align: center")
+							span(ng-controller="ChangePlanFormController")
+								p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
+								button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-success #{translate("yes_please")}
 								p
 									| &nbsp;
 								p
-									a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
-
-
-							div(ng-show="showDowngradeToStudent", style="text-align: center")
-								span(ng-controller="ChangePlanFormController")
-									p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
-									button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-success #{translate("yes_please")}
-									p
-										| &nbsp;
-									p
-										a(href, ng-click="cancelSubscription()", ng-disabled='inflight')  #{translate("no_thanks_cancel_now")}
-
-							div(ng-show="showBasicCancel")
-								p #{translate("sure_you_want_to_cancel")}
-								a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
-								| &nbsp;
-								a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
+									a(href, ng-click="cancelSubscription()", ng-disabled='inflight')  #{translate("no_thanks_cancel_now")}
 
-						span(ng-if="sixpackOpt == 'basic'")
+						div(ng-show="showBasicCancel")
 							p #{translate("sure_you_want_to_cancel")}
 							a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
 							| &nbsp;

+ 1 - 2
services/web/app/views/subscriptions/plans.jade

@@ -100,8 +100,7 @@ block content
 												li
 													br
 													a.btn.btn-info(
-
-														ng-href="#{baseUrl}/user/subscription/new?planCode=collaborator{{ (ui.view == 'annual' ? '-annual' : '') + (plansVariant == 'default' ? planQueryString : '_'+plansVariant)}}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
+														ng-href="#{baseUrl}/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
 													)
 														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
 														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}

+ 2 - 7
services/web/app/views/subscriptions/successful_subscription.jade

@@ -10,13 +10,8 @@ block content
 							h2 #{translate("thanks_for_subscribing")}
 						.alert.alert-success
 							p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>"+subscription.price+"</strong>", collectionDate:"<strong>"+subscription.nextPaymentDueAt+"</strong>"})}
-							span(sixpack-switch="upgrade-success-message")
-								span(sixpack-default)
-									p #{translate("if_you_dont_want_to_be_charged")}
-										a(href="/user/subscription") #{translate("click_here_to_cancel")}.
-								span(sixpack-when="manage-subscription")
-									p #{translate("to_modify_your_subscription_go_to")} 
-										a(href="/user/subscription")  #{translate("manage_subscription")}.
+							p #{translate("to_modify_your_subscription_go_to")} 
+								a(href="/user/subscription")  #{translate("manage_subscription")}.
 						p
 							- if (subscription.groupPlan == true)
 								a.btn.btn-success.btn-large(href="/subscription/group") #{translate("add_your_first_group_member_now")}

+ 1 - 0
services/web/package.json

@@ -47,6 +47,7 @@
     "nodemailer-ses-transport": "^1.3.0",
     "optimist": "0.6.1",
     "passport": "^0.3.2",
+    "passport-ldapauth": "^0.6.0",
     "passport-local": "^1.0.0",
     "pg": "^6.0.3",
     "pg-hstore": "^2.3.2",

+ 2 - 0
services/web/public/coffee/ide.coffee

@@ -11,6 +11,7 @@ define [
 	"ide/references/ReferencesManager"
 	"ide/review-panel/ReviewPanelManager"
 	"ide/SafariScrollPatcher"
+	"ide/FeatureOnboardingController"
 	"ide/settings/index"
 	"ide/share/index"
 	"ide/chat/index"
@@ -67,6 +68,7 @@ define [
 			chatOpen: false
 			pdfLayout: 'sideBySide'
 			reviewPanelOpen: false
+			showCodeCheckerOnboarding: !window.userSettings.syntaxValidation?
 		}
 		$scope.user = window.user
 

+ 35 - 0
services/web/public/coffee/ide/FeatureOnboardingController.coffee

@@ -0,0 +1,35 @@
+define [
+	"base"
+], (App) ->
+	App.controller "FeatureOnboardingController", ($scope, settings) ->
+		$scope.innerStep = 1
+
+		$scope.turnCodeCheckOn = () ->
+			settings.saveSettings({ syntaxValidation: true })
+			$scope.settings.syntaxValidation = true
+			navToInnerStep2()
+			
+		$scope.turnCodeCheckOff = () ->
+			settings.saveSettings({ syntaxValidation: false })
+			$scope.settings.syntaxValidation = false
+			navToInnerStep2()
+
+		$scope.dismiss = () ->
+			$scope.ui.leftMenuShown = false
+			$scope.ui.showCodeCheckerOnboarding = false
+
+		navToInnerStep2 = () ->
+			$scope.innerStep = 2
+			$scope.ui.leftMenuShown = true
+
+		handleKeypress = (e) ->
+			if e.keyCode == 13
+				if $scope.innerStep == 1
+					$scope.turnCodeCheckOn()
+				else
+					$scope.dismiss()
+
+		$(document).on "keypress", handleKeypress
+
+		$scope.$on "$destroy", () -> 
+			$(document).off "keypress", handleKeypress

+ 84 - 22
services/web/public/coffee/ide/connection/ConnectionManager.coffee

@@ -28,32 +28,46 @@ define [], () ->
 			@connected = false
 			@userIsInactive = false
 			@gracefullyReconnecting = false
-			
-			@$scope.connection = 
+
+			@$scope.connection =
 				reconnecting: false
 				# If we need to force everyone to reload the editor
 				forced_disconnect: false
 				inactive_disconnect: false
 
 			@$scope.tryReconnectNow = () =>
-				@tryReconnect()
+				# user manually requested reconnection via "Try now" button
+				@tryReconnectWithRateLimit({force:true})
 
 			@$scope.$on 'cursor:editor:update', () =>
-				@lastUserAction = new Date()
+				@lastUserAction = new Date()  # time of last edit
 				if !@connected
-					@tryReconnect()
+					# user is editing, try to reconnect
+					@tryReconnectWithRateLimit()
 
 			document.querySelector('body').addEventListener 'click', (e) =>
 				if !@connected and e.target.id != 'try-reconnect-now-button'
-					@tryReconnect()
+					# user is editing, try to reconnect
+					@tryReconnectWithRateLimit()
 
 			@ide.socket = io.connect null,
 				reconnect: false
 				'connect timeout': 30 * 1000
 				"force new connection": true
 
+			# The "connect" event is the first event we get back. It only
+			# indicates that the websocket is connected, we still need to
+			# pass authentication to join a project.
+
 			@ide.socket.on "connect", () =>
 				sl_console.log "[socket.io connect] Connected"
+
+			# The next event we should get is an authentication response
+			# from the server, either "connectionAccepted" or
+			# "connectionRejected".
+
+			@ide.socket.on 'connectionAccepted', (message) =>
+				sl_console.log "[socket.io connectionAccepted] allowed to connect"
 				@connected = true
 				@gracefullyReconnecting = false
 				@ide.pushEvent("connected")
@@ -64,16 +78,26 @@ define [], () ->
 					if @$scope.state.loading
 						@$scope.state.load_progress = 70
 
+				# we have passed authentication so we can now join the project
 				setTimeout(() =>
 					@joinProject()
 				, 100)
 
+			@ide.socket.on 'connectionRejected', (err) =>
+				sl_console.log "[socket.io connectionRejected] session not valid or other connection error"
+				# we have failed authentication, usually due to an invalid session cookie
+				return @reportConnectionError(err)
+
+			# Alternatively the attempt to connect can fail completely, so
+			# we never get into the "connect" state.
+
 			@ide.socket.on "connect_failed", () =>
 				@connected = false
 				$scope.$apply () =>
 					@$scope.state.error = "Unable to connect, please view the <u><a href='http://sharelatex.tenderapp.com/help/kb/latex-editor/editor-connection-problems'>connection problems guide</a></u> to fix the issue."
 
-
+			# We can get a "disconnect" event at any point after the
+			# "connect" event.
 
 			@ide.socket.on 'disconnect', () =>
 				sl_console.log "[socket.io disconnect] Disconnected"
@@ -86,6 +110,8 @@ define [], () ->
 				if !$scope.connection.forced_disconnect and !@userIsInactive and !@gracefullyReconnecting
 					@startAutoReconnectCountdown()
 
+			# Site administrators can send the forceDisconnect event to all users
+
 			@ide.socket.on 'forceDisconnect', (message) =>
 				@$scope.$apply () =>
 					@$scope.permissions.write = false
@@ -99,25 +125,33 @@ define [], () ->
 				setTimeout () ->
 					location.reload()
 				, 10 * 1000
-			
+
 			@ide.socket.on "reconnectGracefully", () =>
 				sl_console.log "Reconnect gracefully"
 				@reconnectGracefully()
 
+		# Error reporting, which can reload the page if appropriate
+
+		reportConnectionError: (err) ->
+			sl_console.log "[socket.io] reporting connection error"
+			if err?.message == "not authorized" or err?.message == "invalid session"
+				window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
+			else
+				@ide.socket.disconnect()
+				@ide.showGenericMessageModal("Something went wrong connecting", """
+					Something went wrong connecting to your project. Please refresh is this continues to happen.
+				""")
+
 		joinProject: () ->
 			sl_console.log "[joinProject] joining..."
+			# Note: if the "joinProject" message doesn't reach the server
+			# (e.g. if we are in a disconnected state at this point) the
+			# callback will never be executed
 			@ide.socket.emit 'joinProject', {
 				project_id: @ide.project_id
 			}, (err, project, permissionsLevel, protocolVersion) =>
 				if err?
-					if err.message == "not authorized"
-						window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
-					else
-						@ide.socket.disconnect()
-						@ide.showGenericMessageModal("Something went wrong connecting", """
-							Something went wrong connecting to your project. Please refresh is this continues to happen.
-						""")
-					return
+					return @reportConnectionError(err)
 
 				if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion
 					location.reload(true)
@@ -135,11 +169,13 @@ define [], () ->
 			@tryReconnect()
 
 		disconnect: () ->
+			sl_console.log "[socket.io] disconnecting client"
 			@ide.socket.disconnect()
 
 		startAutoReconnectCountdown: () ->
+			sl_console.log "[ConnectionManager] starting autoreconnect countdown"
 			twoMinutes = 2 * 60 * 1000
-			if @lastUpdated? and new Date() - @lastUpdated > twoMinutes
+			if @lastUserAction? and new Date() - @lastUserAction > twoMinutes
 				# between 1 minute and 3 minutes
 				countdown = 60 + Math.floor(Math.random() * 120)
 			else
@@ -158,10 +194,16 @@ define [], () ->
 			, 200)
 
 		cancelReconnect: () ->
-			clearTimeout @timeoutId if @timeoutId?
-					
+			# clear timeout and set to null so we know there is no countdown running
+			if @timeoutId?
+				sl_console.log "[ConnectionManager] cancelling existing reconnect timer"
+				clearTimeout @timeoutId
+				@timeoutId = null
+
 		decreaseCountdown: () ->
+			@timeoutId = null
 			return if !@$scope.connection.reconnection_countdown?
+			sl_console.log "[ConnectionManager] decreasing countdown", @$scope.connection.reconnection_countdown
 			@$scope.$apply () =>
 				@$scope.connection.reconnection_countdown--
 
@@ -172,13 +214,33 @@ define [], () ->
 				@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
 
 		tryReconnect: () ->
+			sl_console.log "[ConnectionManager] tryReconnect"
 			@cancelReconnect()
 			delete @$scope.connection.reconnection_countdown
 			return if @connected
 			@$scope.connection.reconnecting = true
-			@ide.socket.socket.reconnect()
+			# use socket.io connect() here to make a single attempt, the
+			# reconnect() method makes multiple attempts
+			@ide.socket.socket.connect()
+			# record the time of the last attempt to connect
+			@lastConnectionAttempt = new Date()
 			setTimeout (=> @startAutoReconnectCountdown() if !@connected), 2000
 
+		MIN_RETRY_INTERVAL: 1000 # ms
+		BACKGROUND_RETRY_INTERVAL : 30 * 1000 # ms
+
+		tryReconnectWithRateLimit: (options) ->
+			# bail out if the reconnect is already in progress
+			return if @$scope.connection?.reconnecting
+			# bail out if we are going to reconnect soon anyway
+			reconnectingSoon = @$scope.connection?.reconnection_countdown? and @$scope.connection.reconnection_countdown <= 5
+			clickedTryNow = options?.force  # user requested reconnection
+			return if reconnectingSoon and not clickedTryNow
+			# bail out if we tried reconnecting recently
+			allowedInterval = if clickedTryNow then @MIN_RETRY_INTERVAL else @BACKGROUND_RETRY_INTERVAL
+			return if @lastConnectionAttempt? and new Date() - @lastConnectionAttempt < allowedInterval
+			@tryReconnect()
+
 		disconnectIfInactive: ()->
 			@userIsInactive = (new Date() - @lastUserAction) > @disconnectAfterMs
 			if @userIsInactive and @connected
@@ -200,10 +262,10 @@ define [], () ->
 				setTimeout () =>
 					@reconnectGracefully()
 				, @RECONNECT_GRACEFULLY_RETRY_INTERVAL
-		
+
 		_reconnectGracefullyNow: () ->
 			@gracefullyReconnecting = true
 			@reconnectGracefullyStarted = null
 			# Clear cookie so we don't go to the same backend server
 			$.cookie("SERVERID", "", { expires: -1, path: "/" })
-			@reconnectImmediately()
+			@reconnectImmediately()

+ 1 - 1
services/web/public/coffee/ide/editor/directives/aceEditor/undo/UndoManager.coffee

@@ -237,7 +237,7 @@ define [
 					seen = []
 					return JSON.stringify o, (k,v) ->
 						if (typeof v == 'object')
-							if ( !seen.indexOf(v) )
+							if ( seen.indexOf(v) >= 0 )
 								return '__cycle__'
 							seen.push(v);
 						return v

+ 15 - 0
services/web/public/coffee/main/plans.coffee

@@ -338,6 +338,21 @@ define [
 		$scope.changeCurreny = (newCurrency)->
 			$scope.currencyCode = newCurrency
 
+		# because ternary logic in angular bindings is hard
+		$scope.getCollaboratorPlanCode = () ->
+			view = $scope.ui.view
+			variant = $scope.plansVariant
+			if view == "annual"
+				if variant == "default"
+					return "collaborator-annual"
+				else
+					return "collaborator-annual_#{variant}"
+			else
+				if variant == "default"
+					return "collaborator#{$scope.planQueryString}"
+				else
+					return "collaborator_#{variant}"
+
 		$scope.signUpNowClicked = (plan, annual)->
 			event_tracking.sendMB 'plans-page-start-trial', {plan}
 			if $scope.ui.view == "annual"

+ 2 - 6
services/web/public/coffee/main/subscription-dashboard.coffee

@@ -153,9 +153,7 @@ define [
 			$scope.inflight = true
 			$http.post("/user/subscription/cancel", body)
 				.success ->
-					sixpack.convert 'cancelation-options-view', ->
-						sixpack.convert 'upgrade-success-message', ->
-							location.reload()
+					location.reload()
 				.error ->
 					console.log "something went wrong changing plan"
 
@@ -169,9 +167,7 @@ define [
 			)
 
 		$scope.switchToCancelationView = ->
-			sixpack.participate 'cancelation-options-view', ['basic', 'downgrade-options'], (view, rawResponse)->
-				$scope.view = "cancelation"
-				$scope.sixpackOpt = view
+			$scope.view = "cancelation"
 
 
 

BIN
services/web/public/img/teasers/code-checker/code-checker.gif


BIN
services/web/public/img/teasers/code-checker/code-checker.mp4


+ 1 - 0
services/web/public/stylesheets/app/editor.less

@@ -12,6 +12,7 @@
 @import "./editor/online-users.less";
 @import "./editor/hotkeys.less";
 @import "./editor/review-panel.less";
+@import "./editor/feature-onboarding.less";
 
 .full-size {
 	position: absolute;

+ 78 - 0
services/web/public/stylesheets/app/editor/feature-onboarding.less

@@ -0,0 +1,78 @@
+@feat-onboard-wrapper-width: 820px;
+@feat-onboard-max-text-width: 750px;
+
+.feat-onboard {
+	position: fixed;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	display: flex;
+ 	justify-content: center;
+ 	align-items: center;
+	background-image: linear-gradient(rgba(0, 0, 0, .85), rgba(0, 0, 0, .85));
+	background-repeat: no-repeat;
+	background-position-x: 0;
+	color: #FFF;
+	text-align: center;
+	z-index: 102;
+	transition: background-position ease-in-out @left-menu-animation-duration;
+	overflow: auto;
+}
+
+.feat-onboard-step2 {
+	background-position-x: @left-menu-width;
+
+	~ #left-menu {
+		pointer-events: none;
+
+		.code-check-setting {
+			box-shadow: 0 0 300px 0 #000;
+		}
+	}
+}
+	.feat-onboard-wrapper {
+		width: @feat-onboard-wrapper-width;
+	}
+		.feat-onboard-title {
+			color: @brand-primary;
+			margin-bottom: 40px;
+
+		}
+			.feat-onboard-title-name {
+				color: #FFF;
+				font-weight: bold;
+			}
+
+		.feat-onboard-description {
+			max-width: @feat-onboard-max-text-width;
+			margin: 0 auto 30px;
+			padding: 0 80px;
+		}
+			.feat-onboard-description-name {
+				font-weight: bold;
+			}
+
+	.feat-onboard-video {
+		box-shadow: 0 0 70px 0 rgba(255, 255, 255, 0.3);
+	}
+
+	.feat-onboard-adv-wrapper {
+		text-align: left;
+		margin-bottom: 30px;
+	}
+		.feat-onboard-adv-title {
+			color: #FFF;
+			font-size: 23px;
+		}
+			.feat-onboard-adv-title-highlight {
+				font-weight: bold;
+			}
+
+	.feat-onboard-btn-wrapper {
+		margin-bottom: 10px;
+
+		> .btn {
+			box-shadow: 0 0 70px 0 rgba(255, 255, 255, 0.3);
+		}
+	}

+ 3 - 3
services/web/public/stylesheets/app/editor/left-menu.less

@@ -1,6 +1,6 @@
 #left-menu {
 	position: absolute;
-	width: 260px;
+	width: @left-menu-width;
 	padding: (@line-height-computed / 2);
 	top: 0;
 	bottom: 0;
@@ -8,8 +8,8 @@
 	z-index: 100;
 	overflow-y: auto;
 	overflow-x: hidden;
-	-webkit-transition: left ease-in-out 0.35s;
-	transition: left ease-in-out 0.35s;
+	-webkit-transition: left ease-in-out @left-menu-animation-duration;
+	transition: left ease-in-out @left-menu-animation-duration;
 	font-size: 14px;
 
 	left: -280px;

+ 3 - 0
services/web/public/stylesheets/core/variables.less

@@ -820,6 +820,9 @@
 
 // Custom
 
+@left-menu-width: 260px;
+@left-menu-animation-duration: 0.35s;
+
 @toolbar-border-color: @gray-lighter;
 @file-tree-droppable-background-color: rgb(252, 231, 199);
 

+ 50 - 0
services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -157,6 +157,56 @@ describe "AuthenticationController", ->
 				@res.json.callCount.should.equal 1
 				@res.json.calledWith({message: @info}).should.equal true
 
+	describe 'afterLoginSessionSetup', ->
+
+		beforeEach ->
+			@req.login = sinon.stub().callsArgWith(1, null)
+			@req.session = @session = {passport: {user: @user}}
+			@req.session =
+				passport: {user: {_id: "one"}}
+			@req.session.destroy = sinon.stub()
+			@req.session.save = sinon.stub().callsArgWith(0, null)
+			@req.sessionStore = {generate: sinon.stub()}
+			@UserSessionsManager.trackSession = sinon.stub()
+			@call = (callback) =>
+				@AuthenticationController.afterLoginSessionSetup @req, @user, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.equal null
+				done()
+
+		it 'should call req.login', (done) ->
+			@call (err) =>
+				@req.login.callCount.should.equal 1
+				done()
+
+		it 'should call req.session.save', (done) ->
+			@call (err) =>
+				@req.session.save.callCount.should.equal 1
+				done()
+
+		it 'should call UserSessionsManager.trackSession', (done) ->
+			@call (err) =>
+				@UserSessionsManager.trackSession.callCount.should.equal 1
+				done()
+
+		describe 'when req.session.save produces an error', ->
+
+			beforeEach ->
+				@req.session.save = sinon.stub().callsArgWith(0, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.oneOf [null, undefined]
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not call UserSessionsManager.trackSession', (done) ->
+				@call (err) =>
+					@UserSessionsManager.trackSession.callCount.should.equal 0
+					done()
+
 	describe 'getSessionUser', ->
 
 		it 'should get the user object from session', ->

+ 1 - 1
services/web/test/UnitTests/coffee/User/UserCreatorTests.coffee

@@ -9,7 +9,7 @@ describe "UserCreator", ->
 
 	beforeEach ->
 		self = @
-		@user = {_id:"12390i"}
+		@user = {_id:"12390i", ace: {}}
 		@user.save = sinon.stub().callsArgWith(0)
 		@UserModel = class Project
 			constructor: ->