Просмотр исходного кода

Merge branch 'master' into ns-remove-wufoo-log-links

Nate Stemen 8 лет назад
Родитель
Сommit
c034b0654b
21 измененных файлов с 290 добавлено и 63 удалено
  1. 9 0
      services/web/app/coffee/Features/Analytics/AnalyticsController.coffee
  2. 1 1
      services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee
  3. 11 4
      services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee
  4. 1 1
      services/web/app/coffee/Features/User/UserUpdater.coffee
  5. 21 0
      services/web/app/views/_mixins/faq_search.pug
  6. 0 0
      services/web/app/views/_mixins/links.pug
  7. 74 0
      services/web/app/views/mixins/_pagination.pug
  8. 0 41
      services/web/app/views/project/editor/history.pug
  9. 44 0
      services/web/app/views/project/editor/history/entriesListV2.pug
  10. 3 3
      services/web/app/views/project/editor/history/toolbarV2.pug
  11. 1 1
      services/web/app/views/project/editor/pdf.pug
  12. 19 4
      services/web/public/coffee/ide/history/HistoryV2Manager.coffee
  13. 2 0
      services/web/public/coffee/ide/history/components/historyEntriesList.coffee
  14. 5 1
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  15. 8 1
      services/web/public/stylesheets/app/editor/history-v2.less
  16. 15 0
      services/web/public/stylesheets/core/ol-variables.less
  17. 1 0
      services/web/public/stylesheets/ol-style.less
  18. 20 0
      services/web/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee
  19. 41 3
      services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee
  20. 2 2
      services/web/test/unit/coffee/User/UserUpdaterTests.coffee
  21. 12 1
      services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee

+ 9 - 0
services/web/app/coffee/Features/Analytics/AnalyticsController.coffee

@@ -1,6 +1,7 @@
 AnalyticsManager = require "./AnalyticsManager"
 AnalyticsManager = require "./AnalyticsManager"
 Errors = require "../Errors/Errors"
 Errors = require "../Errors/Errors"
 AuthenticationController = require("../Authentication/AuthenticationController")
 AuthenticationController = require("../Authentication/AuthenticationController")
+InstitutionsAPI = require("../Institutions/InstitutionsAPI")
 GeoIpLookup = require '../../infrastructure/GeoIpLookup'
 GeoIpLookup = require '../../infrastructure/GeoIpLookup'
 
 
 module.exports = AnalyticsController =
 module.exports = AnalyticsController =
@@ -23,6 +24,14 @@ module.exports = AnalyticsController =
 		AnalyticsManager.recordEvent user_id, req.params.event, req.body, (error) ->
 		AnalyticsManager.recordEvent user_id, req.params.event, req.body, (error) ->
 			respondWith(error, res, next)
 			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) ->
 respondWith = (error, res, next) ->
 	if error instanceof Errors.ServiceNotConfiguredError
 	if error instanceof Errors.ServiceNotConfiguredError
 		# ignore, no-op
 		# ignore, no-op

+ 1 - 1
services/web/app/coffee/Features/Analytics/AnalyticsRouter.coffee

@@ -23,4 +23,4 @@ module.exports =
 
 
 		publicApiRouter.use '/analytics/uniExternalCollaboration',
 		publicApiRouter.use '/analytics/uniExternalCollaboration',
 			AuthenticationController.httpAuth,
 			AuthenticationController.httpAuth,
-			AnalyticsProxy.call('/uniExternalCollaboration')
+			AnalyticsProxy.call('/uniExternalCollaboration')

+ 11 - 4
services/web/app/coffee/Features/Institutions/InstitutionsAPI.coffee

@@ -9,15 +9,22 @@ module.exports = InstitutionsAPI =
 			method: 'GET'
 			method: 'GET'
 			path: "/api/v2/institutions/#{institutionId.toString()}/affiliations"
 			path: "/api/v2/institutions/#{institutionId.toString()}/affiliations"
 			defaultErrorMessage: "Couldn't get institution 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) ->) ->
 	getUserAffiliations: (userId, callback = (error, body) ->) ->
 		makeAffiliationRequest {
 		makeAffiliationRequest {
 			method: 'GET'
 			method: 'GET'
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
 			defaultErrorMessage: "Couldn't get user affiliations"
 			defaultErrorMessage: "Couldn't get user affiliations"
-		}, callback
+		}, (error, body) -> callback(error, body or [])
 
 
 
 
 	addAffiliation: (userId, email, affiliationOptions, callback) ->
 	addAffiliation: (userId, email, affiliationOptions, callback) ->
@@ -25,11 +32,11 @@ module.exports = InstitutionsAPI =
 			callback = affiliationOptions
 			callback = affiliationOptions
 			affiliationOptions = {}
 			affiliationOptions = {}
 
 
-		{ university, department, role } = affiliationOptions
+		{ university, department, role, confirmedAt } = affiliationOptions
 		makeAffiliationRequest {
 		makeAffiliationRequest {
 			method: 'POST'
 			method: 'POST'
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
 			path: "/api/v2/users/#{userId.toString()}/affiliations"
-			body: { email, university, department, role }
+			body: { email, university, department, role, confirmedAt }
 			defaultErrorMessage: "Couldn't create affiliation"
 			defaultErrorMessage: "Couldn't create affiliation"
 		}, callback
 		}, callback
 
 

+ 1 - 1
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -166,7 +166,7 @@ module.exports = UserUpdater =
 		email = EmailHelper.parseEmail(email)
 		email = EmailHelper.parseEmail(email)
 		return callback(new Error('invalid email')) if !email?
 		return callback(new Error('invalid email')) if !email?
 		logger.log {userId, email}, 'confirming user email'
 		logger.log {userId, email}, 'confirming user email'
-		addAffiliation userId, email, (error) =>
+		addAffiliation userId, email, {confirmedAt: confirmedAt}, (error) =>
 			if error?
 			if error?
 				logger.err error: error, 'problem adding affiliation while confirming email'
 				logger.err error: error, 'problem adding affiliation while confirming email'
 				return callback(error)
 				return callback(error)

+ 21 - 0
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')

+ 0 - 0
services/web/app/views/_mixins_links.pug → services/web/app/views/_mixins/links.pug


+ 74 - 0
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 »

+ 0 - 41
services/web/app/views/project/editor/history.pug

@@ -1,45 +1,4 @@
 div#history(ng-show="ui.view == 'history'")
 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 &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')"
-					) #{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/entriesListV1
 	include ./history/entriesListV2
 	include ./history/entriesListV2
 
 

+ 44 - 0
services/web/app/views/project/editor/history/entriesListV2.pug

@@ -6,11 +6,13 @@ aside.change-list(
 		ng-if="!history.showOnlyLabels && !history.error"
 		ng-if="!history.showOnlyLabels && !history.error"
 		entries="history.updates"
 		entries="history.updates"
 		current-user="user"
 		current-user="user"
+		current-user-is-owner="project.owner._id === user.id"
 		users="projectUsers"
 		users="projectUsers"
 		load-entries="loadMore()"
 		load-entries="loadMore()"
 		load-disabled="history.loading || history.atEnd"
 		load-disabled="history.loading || history.atEnd"
 		load-initialize="ui.view == 'history'"
 		load-initialize="ui.view == 'history'"
 		is-loading="history.loading"
 		is-loading="history.loading"
+		free-history-limit-hit="history.freeHistoryLimitHit"
 		on-entry-select="handleEntrySelect(selectedEntry)"
 		on-entry-select="handleEntrySelect(selectedEntry)"
 		on-label-delete="handleLabelDelete(label)"
 		on-label-delete="handleLabelDelete(label)"
 	)
 	)
@@ -134,6 +136,48 @@ script(type="text/ng-template", id="historyEntriesListTpl")
 			.loading(ng-show="$ctrl.isLoading")
 			.loading(ng-show="$ctrl.isLoading")
 				i.fa.fa-spin.fa-refresh
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp; #{translate("loading")}...
 				| &nbsp;&nbsp; #{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 &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
+					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")
 script(type="text/ng-template", id="historyEntryTpl")
 	.history-entry(
 	.history-entry(

+ 3 - 3
services/web/app/views/project/editor/history/toolbarV2.pug

@@ -6,7 +6,7 @@
 		i.fa.fa-spin.fa-refresh
 		i.fa.fa-spin.fa-refresh
 		| &nbsp;&nbsp; #{translate("loading")}...
 		| &nbsp;&nbsp; #{translate("loading")}...
 	span.history-toolbar-selected-version(
 	span.history-toolbar-selected-version(
-		ng-show="!history.loadingFileTree && !history.showOnlyLabels && !history.error"
+		ng-show="!history.loadingFileTree && !history.showOnlyLabels && history.selection.updates.length && !history.error"
 	) #{translate("browsing_project_as_of")}&nbsp;
 	) #{translate("browsing_project_as_of")}&nbsp;
 		time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }}
 		time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }}
 	span.history-toolbar-selected-version(
 	span.history-toolbar-selected-version(
@@ -19,13 +19,13 @@
 		button.history-toolbar-btn(
 		button.history-toolbar-btn(
 			ng-click="showAddLabelDialog();"
 			ng-click="showAddLabelDialog();"
 			ng-if="!history.showOnlyLabels"
 			ng-if="!history.showOnlyLabels"
-			ng-disabled="history.loadingFileTree"
+			ng-disabled="history.loadingFileTree || history.selection.updates.length == 0"
 		) 
 		) 
 			i.fa.fa-tag
 			i.fa.fa-tag
 			| &nbsp;#{translate("history_label_this_version")}
 			| &nbsp;#{translate("history_label_this_version")}
 		button.history-toolbar-btn(
 		button.history-toolbar-btn(
 			ng-click="toggleHistoryViewMode();"
 			ng-click="toggleHistoryViewMode();"
-			ng-disabled="history.loadingFileTree"
+			ng-disabled="history.loadingFileTree || history.selection.updates.length == 0"
 		) 
 		) 
 			i.fa.fa-exchange
 			i.fa.fa-exchange
 			| &nbsp;#{translate("compare_to_another_version")}
 			| &nbsp;#{translate("compare_to_another_version")}

+ 1 - 1
services/web/app/views/project/editor/pdf.pug

@@ -243,7 +243,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				dbl-click-callback="syncToCode"
 				dbl-click-callback="syncToCode"
 			)
 			)
 			iframe(
 			iframe(
-				ng-src="{{ pdf.url }}"
+				ng-src="{{ pdf.url | trusted }}"
 				ng-if="settings.pdfViewer == 'native'"
 				ng-if="settings.pdfViewer == 'native'"
 			)
 			)
 
 

+ 19 - 4
services/web/public/coffee/ide/history/HistoryV2Manager.coffee

@@ -82,6 +82,8 @@ define [
 				viewMode: null
 				viewMode: null
 				nextBeforeTimestamp: null
 				nextBeforeTimestamp: null
 				atEnd: false
 				atEnd: false
+				userHasFullFeature: @$scope.project?.features?.versioning or false
+				freeHistoryLimitHit: false
 				selection: {
 				selection: {
 					label: null
 					label: null
 					updates: []
 					updates: []
@@ -232,9 +234,11 @@ define [
 						@$scope.history.labels = @_sortLabelsByVersionAndDate response.labels.data
 						@$scope.history.labels = @_sortLabelsByVersionAndDate response.labels.data
 					@_loadUpdates(updatesData.updates)
 					@_loadUpdates(updatesData.updates)
 					@$scope.history.nextBeforeTimestamp = updatesData.nextBeforeTimestamp
 					@$scope.history.nextBeforeTimestamp = updatesData.nextBeforeTimestamp
-					if !updatesData.nextBeforeTimestamp?
+					if !updatesData.nextBeforeTimestamp? or @$scope.history.freeHistoryLimitHit
 						@$scope.history.atEnd = true
 						@$scope.history.atEnd = true
 					@$scope.history.loading = false
 					@$scope.history.loading = false
+					if @$scope.history.updates.length == 0
+						@$scope.history.loadingFileTree = false
 				.catch (error) =>
 				.catch (error) =>
 					{ status, statusText } = error
 					{ status, statusText } = error
 					@$scope.history.error = { status, statusText }
 					@$scope.history.error = { status, statusText }
@@ -387,23 +391,34 @@ define [
 
 
 		_loadUpdates: (updates = []) ->
 		_loadUpdates: (updates = []) ->
 			previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1]
 			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 []
 				for user in update.meta.users or []
 					if user?
 					if user?
 						user.hue = ColorManager.getHueForUserId(user.id)
 						user.hue = ColorManager.getHueForUserId(user.id)
 
 
 				if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
 				if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
 					update.meta.first_in_day = true
 					update.meta.first_in_day = true
-
+				
 				update.selectedFrom = false
 				update.selectedFrom = false
 				update.selectedTo = false
 				update.selectedTo = false
 				update.inSelection = false
 				update.inSelection = false
 
 
 				previousUpdate = update
 				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
 			firstLoad = @$scope.history.updates.length == 0
 
 
+			if !@$scope.history.userHasFullFeature and cutOffIndex?
+				updates = updates.slice 0, cutOffIndex
+
 			@$scope.history.updates =
 			@$scope.history.updates =
 				@$scope.history.updates.concat(updates)
 				@$scope.history.updates.concat(updates)
 
 

+ 2 - 0
services/web/public/coffee/ide/history/components/historyEntriesList.coffee

@@ -30,6 +30,8 @@ define [
 			loadInitialize: "<"
 			loadInitialize: "<"
 			isLoading: "<"
 			isLoading: "<"
 			currentUser: "<"
 			currentUser: "<"
+			freeHistoryLimitHit: "<"
+			currentUserIsOwner: "<"
 			onEntrySelect: "&"
 			onEntrySelect: "&"
 			onLabelDelete: "&"
 			onLabelDelete: "&"
 		controller: historyEntriesListController
 		controller: historyEntriesListController

+ 5 - 1
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -11,6 +11,10 @@ define [
 	# and then again on ack.
 	# and then again on ack.
 	AUTO_COMPILE_DEBOUNCE = 2000
 	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, localStorage) ->
 	App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) ->
 		# enable per-user containers by default
 		# enable per-user containers by default
 		perUserCompile = true
 		perUserCompile = true
@@ -212,7 +216,7 @@ define [
 
 
 		buildPdfDownloadUrl = (pdfDownloadDomain, path)->
 		buildPdfDownloadUrl = (pdfDownloadDomain, path)->
 			 #we only download builds from compiles server for security reasons
 			 #we only download builds from compiles server for security reasons
-			if pdfDownloadDomain? and path.indexOf("build") != -1
+			if pdfDownloadDomain? and path? and path.indexOf("build") != -1
 				return "#{pdfDownloadDomain}#{path}"
 				return "#{pdfDownloadDomain}#{path}"
 			else
 			else
 				return path
 				return path

+ 8 - 1
services/web/public/stylesheets/app/editor/history-v2.less

@@ -19,6 +19,7 @@
 		white-space: nowrap;
 		white-space: nowrap;
 		overflow: hidden;
 		overflow: hidden;
 		text-overflow: ellipsis;
 		text-overflow: ellipsis;
+		margin-right: (@line-height-computed / 2);
 	}
 	}
 		.history-toolbar-time,
 		.history-toolbar-time,
 		.history-toolbar-selected-label {
 		.history-toolbar-selected-label {
@@ -33,7 +34,7 @@
 			.btn-xs;
 			.btn-xs;
 			padding-left: @padding-small-horizontal;
 			padding-left: @padding-small-horizontal;
 			padding-right: @padding-small-horizontal;
 			padding-right: @padding-small-horizontal;
-			margin-left: (@line-height-computed / 2);
+			margin-right: (@line-height-computed / 2);
 		}
 		}
 	.history-toolbar-entries-list {
 	.history-toolbar-entries-list {
 		flex: 0 0 @changesListWidth;
 		flex: 0 0 @changesListWidth;
@@ -173,6 +174,12 @@
 				}
 				}
 			}
 			}
 
 
+.history-entries-list-upgrade-prompt {
+	background-color: #FFF;
+	margin-bottom: 2px;
+	padding: 5px 10px;
+}
+
 .history-labels-list {
 .history-labels-list {
 	.history-entries;
 	.history-entries;
 	overflow-y: auto;
 	overflow-y: auto;

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

@@ -277,6 +277,21 @@
 @chat-new-message-textarea-bg     : @ol-blue-gray-1;
 @chat-new-message-textarea-bg     : @ol-blue-gray-1;
 @chat-new-message-textarea-color  : @ol-blue-gray-6;
 @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
 @pdf-top-offset                 : @toolbar-small-height;
 @pdf-top-offset                 : @toolbar-small-height;
 @pdf-bg                         : @ol-blue-gray-1;
 @pdf-bg                         : @ol-blue-gray-1;

+ 1 - 0
services/web/public/stylesheets/ol-style.less

@@ -8,6 +8,7 @@
 @import "_ol_style_includes.less";
 @import "_ol_style_includes.less";
 @import "components/embed-responsive.less";
 @import "components/embed-responsive.less";
 @import "components/icons.less";
 @import "components/icons.less";
+@import "components/pagination.less";
 
 
 // Pages
 // Pages
 @import "app/about.less";
 @import "app/about.less";

+ 20 - 0
services/web/test/unit/coffee/Analytics/AnalyticsControllerTests.coffee

@@ -17,9 +17,13 @@ describe 'AnalyticsController', ->
 			updateEditingSession: sinon.stub().callsArgWith(3)
 			updateEditingSession: sinon.stub().callsArgWith(3)
 			recordEvent: sinon.stub().callsArgWith(3)
 			recordEvent: sinon.stub().callsArgWith(3)
 
 
+		@InstitutionsAPI =
+			getInstitutionLicences: sinon.stub().callsArgWith(4)
+
 		@controller = SandboxedModule.require modulePath, requires:
 		@controller = SandboxedModule.require modulePath, requires:
 			"./AnalyticsManager":@AnalyticsManager
 			"./AnalyticsManager":@AnalyticsManager
 			"../Authentication/AuthenticationController":@AuthenticationController
 			"../Authentication/AuthenticationController":@AuthenticationController
+			"../Institutions/InstitutionsAPI":@InstitutionsAPI
 			"logger-sharelatex":
 			"logger-sharelatex":
 				log:->
 				log:->
 			'../../infrastructure/GeoIpLookup': @GeoIpLookup =
 			'../../infrastructure/GeoIpLookup': @GeoIpLookup =
@@ -66,3 +70,19 @@ describe 'AnalyticsController', ->
 			@controller.recordEvent @req, @res
 			@controller.recordEvent @req, @res
 			@AnalyticsManager.recordEvent.calledWith(@req.sessionID, @req.params["event"], @req.body).should.equal true
 			@AnalyticsManager.recordEvent.calledWith(@req.sessionID, @req.params["event"], @req.body).should.equal true
 			done()
 			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()

+ 41 - 3
services/web/test/unit/coffee/Institutions/InstitutionsAPITests.coffee

@@ -11,12 +11,12 @@ describe "InstitutionsAPI", ->
 
 
 	beforeEach ->
 	beforeEach ->
 		@logger = err: sinon.stub(), log: ->
 		@logger = err: sinon.stub(), log: ->
-		settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
+		@settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
 		@request = sinon.stub()
 		@request = sinon.stub()
 		@InstitutionsAPI = SandboxedModule.require modulePath, requires:
 		@InstitutionsAPI = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": @logger
 			"logger-sharelatex": @logger
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
-			'settings-sharelatex': settings
+			'settings-sharelatex': @settings
 			'request': @request
 			'request': @request
 
 
 		@stubbedUser = 
 		@stubbedUser = 
@@ -40,6 +40,34 @@ describe "InstitutionsAPI", ->
 				should.not.exist(requestOptions.body)
 				should.not.exist(requestOptions.body)
 				body.should.equal responseBody
 				body.should.equal responseBody
 				done()
 				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', ->
 	describe 'getUserAffiliations', ->
 		it 'get affiliations', (done)->
 		it 'get affiliations', (done)->
@@ -65,6 +93,14 @@ describe "InstitutionsAPI", ->
 				err.message.should.have.string body.errors
 				err.message.should.have.string body.errors
 				done()
 				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', ->
 	describe 'addAffiliation', ->
 		beforeEach ->
 		beforeEach ->
 			@request.callsArgWith(1, null, { statusCode: 201 })
 			@request.callsArgWith(1, null, { statusCode: 201 })
@@ -74,6 +110,7 @@ describe "InstitutionsAPI", ->
 				university: { id: 1 }
 				university: { id: 1 }
 				role: 'Prof'
 				role: 'Prof'
 				department: 'Math'
 				department: 'Math'
+				confirmedAt: new Date()
 			@InstitutionsAPI.addAffiliation @stubbedUser._id, @newEmail, affiliationOptions, (err)=>
 			@InstitutionsAPI.addAffiliation @stubbedUser._id, @newEmail, affiliationOptions, (err)=>
 				should.not.exist(err)
 				should.not.exist(err)
 				@request.calledOnce.should.equal true
 				@request.calledOnce.should.equal true
@@ -83,11 +120,12 @@ describe "InstitutionsAPI", ->
 				requestOptions.method.should.equal 'POST'
 				requestOptions.method.should.equal 'POST'
 
 
 				body = requestOptions.body
 				body = requestOptions.body
-				Object.keys(body).length.should.equal 4
+				Object.keys(body).length.should.equal 5
 				body.email.should.equal @newEmail
 				body.email.should.equal @newEmail
 				body.university.should.equal affiliationOptions.university
 				body.university.should.equal affiliationOptions.university
 				body.department.should.equal affiliationOptions.department
 				body.department.should.equal affiliationOptions.department
 				body.role.should.equal affiliationOptions.role
 				body.role.should.equal affiliationOptions.role
+				body.confirmedAt.should.equal affiliationOptions.confirmedAt
 				done()
 				done()
 
 
 		it 'handle error', (done)->
 		it 'handle error', (done)->

+ 2 - 2
services/web/test/unit/coffee/User/UserUpdaterTests.coffee

@@ -241,7 +241,7 @@ describe "UserUpdater", ->
 			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
 			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
 				should.not.exist(err)
 				should.not.exist(err)
 				@addAffiliation.calledOnce.should.equal true
 				@addAffiliation.calledOnce.should.equal true
-				sinon.assert.calledWith(@addAffiliation, @stubbedUser._id, @newEmail)
+				sinon.assert.calledWith(@addAffiliation, @stubbedUser._id, @newEmail, { confirmedAt: new Date() } )
 				done()
 				done()
 
 
 		it 'handle error', (done)->
 		it 'handle error', (done)->
@@ -264,7 +264,7 @@ describe "UserUpdater", ->
 				done()
 				done()
 
 
 		it 'handle affiliation error', (done)->
 		it 'handle affiliation error', (done)->
-			@addAffiliation.callsArgWith(2, new Error('nope'))
+			@addAffiliation.callsArgWith(3, new Error('nope'))
 			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
 			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
 				should.exist(err)
 				should.exist(err)
 				@UserUpdater.updateUser.called.should.equal false
 				@UserUpdater.updateUser.called.should.equal false

+ 12 - 1
services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee

@@ -4,16 +4,21 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) ->
 			@scope =
 			@scope =
 				$watch: sinon.stub()
 				$watch: sinon.stub()
 				$on: sinon.stub()
 				$on: sinon.stub()
+				project:
+					features:
+						versioning: true
 			@ide = {}
 			@ide = {}
 			@historyManager = new HistoryV2Manager(@ide, @scope)
 			@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({
 			expect(@scope.history).to.deep.equal({
 				isV2: true
 				isV2: true
 				updates: []
 				updates: []
 				viewMode: null
 				viewMode: null
 				nextBeforeTimestamp: null
 				nextBeforeTimestamp: null
 				atEnd: false
 				atEnd: false
+				userHasFullFeature: true
+				freeHistoryLimitHit: false
 				selection: {
 				selection: {
 					label: null
 					label: null
 					updates: []
 					updates: []
@@ -32,6 +37,12 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) ->
 				selectedFile: null
 				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", ->
 		describe "_perDocSummaryOfUpdates", ->
 			it "should return the range of updates for the docs", ->
 			it "should return the range of updates for the docs", ->
 				result = @historyManager._perDocSummaryOfUpdates([{
 				result = @historyManager._perDocSummaryOfUpdates([{