aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorStas Vilchik <vilchiks@gmail.com>2015-05-20 10:54:59 +0200
committerStas Vilchik <vilchiks@gmail.com>2015-05-22 15:53:42 +0200
commite009561a5e3210480c7bef0ee20cebbc83d531bc (patch)
treef12b4c8f21213d882cd3ca1427cc7fc48b2df4b6 /server
parent06cafc9839cb231a8f71e1d1c41da43ac1dc0139 (diff)
downloadsonarqube-e009561a5e3210480c7bef0ee20cebbc83d531bc.tar.gz
sonarqube-e009561a5e3210480c7bef0ee20cebbc83d531bc.zip
SONAR-6565 refactor users page
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/Gruntfile.coffee19
-rw-r--r--server/sonar-web/src/main/js/apps/users/app.js47
-rw-r--r--server/sonar-web/src/main/js/apps/users/change-password-view.js37
-rw-r--r--server/sonar-web/src/main/js/apps/users/create-view.js33
-rw-r--r--server/sonar-web/src/main/js/apps/users/deactivate-view.js32
-rw-r--r--server/sonar-web/src/main/js/apps/users/form-view.js52
-rw-r--r--server/sonar-web/src/main/js/apps/users/header-view.js25
-rw-r--r--server/sonar-web/src/main/js/apps/users/layout.js16
-rw-r--r--server/sonar-web/src/main/js/apps/users/list-footer-view.js34
-rw-r--r--server/sonar-web/src/main/js/apps/users/list-item-view.js86
-rw-r--r--server/sonar-web/src/main/js/apps/users/list-view.js11
-rw-r--r--server/sonar-web/src/main/js/apps/users/search-view.js49
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-change-password.hbs24
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-deactivate.hbs13
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-form.hbs53
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-header.hbs9
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-layout.hbs6
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-list-footer.hbs6
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs46
-rw-r--r--server/sonar-web/src/main/js/apps/users/templates/users-search.hbs6
-rw-r--r--server/sonar-web/src/main/js/apps/users/update-view.js30
-rw-r--r--server/sonar-web/src/main/js/apps/users/user.js71
-rw-r--r--server/sonar-web/src/main/js/apps/users/users.js40
-rw-r--r--server/sonar-web/src/main/less/components.less1
-rw-r--r--server/sonar-web/src/main/less/components/modals.less1
-rw-r--r--server/sonar-web/src/main/less/components/search.less24
-rw-r--r--server/sonar-web/src/main/less/init/icons.less5
-rw-r--r--server/sonar-web/src/main/less/init/misc.less9
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/controllers/users_controller.rb92
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/views/users/index.html.erb69
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb6
-rwxr-xr-xserver/sonar-web/src/main/webapp/fonts/sonar-5.2.eotbin17664 -> 17992 bytes
-rwxr-xr-xserver/sonar-web/src/main/webapp/fonts/sonar-5.2.svg2
-rwxr-xr-xserver/sonar-web/src/main/webapp/fonts/sonar-5.2.ttfbin17500 -> 17828 bytes
-rwxr-xr-xserver/sonar-web/src/main/webapp/fonts/sonar-5.2.woffbin17576 -> 17904 bytes
-rw-r--r--server/sonar-web/src/test/js/users-spec.js406
-rw-r--r--server/sonar-web/src/test/json/users-spec/error.json7
-rw-r--r--server/sonar-web/src/test/json/users-spec/search-big-1.json19
-rw-r--r--server/sonar-web/src/test/json/users-spec/search-big-2.json13
-rw-r--r--server/sonar-web/src/test/json/users-spec/search-created.json34
-rw-r--r--server/sonar-web/src/test/json/users-spec/search-filtered.json13
-rw-r--r--server/sonar-web/src/test/json/users-spec/search-updated.json25
-rw-r--r--server/sonar-web/src/test/json/users-spec/search.json25
-rw-r--r--server/sonar-web/src/test/server-coverage.js2
-rw-r--r--server/sonar-web/src/test/views/users.jade5
45 files changed, 1341 insertions, 162 deletions
diff --git a/server/sonar-web/Gruntfile.coffee b/server/sonar-web/Gruntfile.coffee
index b9431a16328..82a5708b8f9 100644
--- a/server/sonar-web/Gruntfile.coffee
+++ b/server/sonar-web/Gruntfile.coffee
@@ -158,6 +158,10 @@ module.exports = (grunt) ->
name: 'apps/markdown/app'
out: '<%= ASSETS_PATH %>/js/apps/markdown/app.js'
+ users: options:
+ name: 'apps/users/app'
+ out: '<%= ASSETS_PATH %>/js/apps/users/app.js'
+
parallel:
build:
@@ -178,6 +182,7 @@ module.exports = (grunt) ->
'requirejs:nav'
'requirejs:issueFilterWidget'
'requirejs:markdown'
+ 'requirejs:users'
]
casper:
options: grunt: true
@@ -197,6 +202,7 @@ module.exports = (grunt) ->
'casper:treemap'
'casper:ui'
'casper:workspace'
+ 'casper:users'
]
@@ -254,6 +260,9 @@ module.exports = (grunt) ->
'<%= BUILD_PATH %>/js/apps/markdown/templates.js': [
'<%= SOURCE_PATH %>/js/apps/markdown/templates/**/*.hbs'
]
+ '<%= BUILD_PATH %>/js/apps/users/templates.js': [
+ '<%= SOURCE_PATH %>/js/apps/users/templates/**/*.hbs'
+ ]
clean:
@@ -299,15 +308,21 @@ module.exports = (grunt) ->
port: expressPort
testCoverageLight:
options:
+ concise: false
verbose: true
+ 'no-colors': false
src: ['src/test/js/**/*<%= grunt.option("spec") %>*.js']
single:
options:
+ concise: false
verbose: true
+ 'no-colors': false
src: ['src/test/js/<%= grunt.option("spec") %>-spec.js']
testfile:
options:
+ concise: false
verbose: true
+ 'no-colors': false
src: ['<%= grunt.option("file") %>']
apiDocumentation:
@@ -340,6 +355,8 @@ module.exports = (grunt) ->
src: ['src/test/js/ui*.js']
workspace:
src: ['src/test/js/workspace*.js']
+ users:
+ src: ['src/test/js/users*.js']
uglify:
build:
@@ -402,7 +419,7 @@ module.exports = (grunt) ->
tasks: ['copy:js', 'concat:build', 'copy:assets-all-js']
handlebars:
- files: '<%= SOURCE_PATH %>/hbs/**/*.hbs'
+ files: '<%= SOURCE_PATH %>/**/*.hbs'
tasks: ['handlebars:build', 'copy:assets-all-js']
diff --git a/server/sonar-web/src/main/js/apps/users/app.js b/server/sonar-web/src/main/js/apps/users/app.js
new file mode 100644
index 00000000000..9fa9df3759a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/app.js
@@ -0,0 +1,47 @@
+define([
+ './layout',
+ './users',
+ './header-view',
+ './search-view',
+ './list-view',
+ './list-footer-view'
+], function (Layout, Users, HeaderView, SearchView, ListView, ListFooterView) {
+
+ var App = new Marionette.Application(),
+ init = function (options) {
+ // Layout
+ this.layout = new Layout({ el: options.el });
+ this.layout.render();
+
+ // Collection
+ this.users = new Users();
+
+ // Header View
+ this.headerView = new HeaderView({ collection: this.users });
+ this.layout.headerRegion.show(this.headerView);
+
+ // Search View
+ this.searchView = new SearchView({ collection: this.users });
+ this.layout.searchRegion.show(this.searchView);
+
+ // List View
+ this.listView = new ListView({ collection: this.users });
+ this.layout.listRegion.show(this.listView);
+
+ // List Footer View
+ this.listFooterView = new ListFooterView({ collection: this.users });
+ this.layout.listFooterRegion.show(this.listFooterView);
+
+ // Go!
+ this.users.fetch();
+ };
+
+ App.on('start', function (options) {
+ window.requestMessages().done(function () {
+ init.call(App, options);
+ });
+ });
+
+ return App;
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/change-password-view.js b/server/sonar-web/src/main/js/apps/users/change-password-view.js
new file mode 100644
index 00000000000..cd2a892fc2b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/change-password-view.js
@@ -0,0 +1,37 @@
+define([
+ 'components/common/modal-form',
+ './templates'
+], function (ModalForm) {
+
+ return ModalForm.extend({
+ template: Templates['users-change-password'],
+
+ onFormSubmit: function () {
+ ModalForm.prototype.onFormSubmit.apply(this, arguments);
+ this.sendRequest();
+ },
+
+ sendRequest: function () {
+ var that = this,
+ password = this.$('#change-user-password-password').val(),
+ confirmation = this.$('#change-user-password-password-confirmation').val();
+ if (password !== confirmation) {
+ that.showErrors([{ msg: 'New password and its confirmation do not match' }]);
+ return;
+ }
+ this.disableForm();
+ return this.model.changePassword(password, {
+ statusCode: {
+ // do not show global error
+ 400: null
+ }
+ }).done(function () {
+ that.close();
+ }).fail(function (jqXHR) {
+ that.enableForm();
+ that.showErrors(jqXHR.responseJSON.errors, jqXHR.responseJSON.warnings);
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/create-view.js b/server/sonar-web/src/main/js/apps/users/create-view.js
new file mode 100644
index 00000000000..026f8095056
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/create-view.js
@@ -0,0 +1,33 @@
+define([
+ './user',
+ './form-view'
+], function (User, FormView) {
+
+ return FormView.extend({
+
+ sendRequest: function () {
+ var that = this,
+ user = new User({
+ login: this.$('#create-user-login').val(),
+ name: this.$('#create-user-name').val(),
+ email: this.$('#create-user-email').val(),
+ password: this.$('#create-user-password').val(),
+ scmAccounts: this.getScmAccounts()
+ });
+ this.disableForm();
+ return user.save(null, {
+ statusCode: {
+ // do not show global error
+ 400: null
+ }
+ }).done(function () {
+ that.collection.refresh();
+ that.close();
+ }).fail(function (jqXHR) {
+ that.enableForm();
+ that.showErrors(jqXHR.responseJSON.errors, jqXHR.responseJSON.warnings);
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/deactivate-view.js b/server/sonar-web/src/main/js/apps/users/deactivate-view.js
new file mode 100644
index 00000000000..cf4c4654984
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/deactivate-view.js
@@ -0,0 +1,32 @@
+define([
+ 'components/common/modal-form',
+ './templates'
+], function (ModalForm) {
+
+ return ModalForm.extend({
+ template: Templates['users-deactivate'],
+
+ onFormSubmit: function () {
+ ModalForm.prototype.onFormSubmit.apply(this, arguments);
+ this.sendRequest();
+ },
+
+ sendRequest: function () {
+ var that = this,
+ collection = this.model.collection;
+ return this.model.destroy({
+ wait: true,
+ statusCode: {
+ // do not show global error
+ 400: null
+ }
+ }).done(function () {
+ collection.total--;
+ that.close();
+ }).fail(function (jqXHR) {
+ that.showErrors(jqXHR.responseJSON.errors, jqXHR.responseJSON.warnings);
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/form-view.js b/server/sonar-web/src/main/js/apps/users/form-view.js
new file mode 100644
index 00000000000..2cf2e1ac3f1
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/form-view.js
@@ -0,0 +1,52 @@
+define([
+ 'components/common/modal-form',
+ './templates'
+], function (ModalForm) {
+
+ var $ = jQuery;
+
+ return ModalForm.extend({
+ template: Templates['users-form'],
+
+ events: function () {
+ return _.extend(ModalForm.prototype.events.apply(this, arguments), {
+ 'click #create-user-add-scm-account': 'onAddScmAccountClick'
+ });
+ },
+
+ onRender: function () {
+ ModalForm.prototype.onRender.apply(this, arguments);
+ this.$('[data-toggle="tooltip"]').tooltip({ container: 'body', placement: 'bottom' });
+ },
+
+ onClose: function () {
+ ModalForm.prototype.onClose.apply(this, arguments);
+ this.$('[data-toggle="tooltip"]').tooltip('destroy');
+ },
+
+ onFormSubmit: function () {
+ ModalForm.prototype.onFormSubmit.apply(this, arguments);
+ this.sendRequest();
+ },
+
+ onAddScmAccountClick: function (e) {
+ e.preventDefault();
+ this.addScmAccount();
+ },
+
+ getScmAccounts: function () {
+ var scmAccounts = this.$('[name="scmAccounts"]').map(function () {
+ return $(this).val();
+ }).toArray();
+ return scmAccounts.filter(function (value) {
+ return !!value;
+ });
+ },
+
+ addScmAccount: function () {
+ var fields = this.$('[name="scmAccounts"]');
+ fields.first().clone().val('').insertAfter(fields.last());
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/header-view.js b/server/sonar-web/src/main/js/apps/users/header-view.js
new file mode 100644
index 00000000000..c8b76193b4c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/header-view.js
@@ -0,0 +1,25 @@
+define([
+ './create-view',
+ './templates'
+], function (CreateView) {
+
+ return Marionette.ItemView.extend({
+ template: Templates['users-header'],
+
+ events: {
+ 'click #users-create': 'onCreateClick'
+ },
+
+ onCreateClick: function (e) {
+ e.preventDefault();
+ this.createUser();
+ },
+
+ createUser: function () {
+ new CreateView({
+ collection: this.collection
+ }).render();
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/layout.js b/server/sonar-web/src/main/js/apps/users/layout.js
new file mode 100644
index 00000000000..d2b625162e0
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/layout.js
@@ -0,0 +1,16 @@
+define([
+ './templates'
+], function () {
+
+ return Marionette.Layout.extend({
+ template: Templates['users-layout'],
+
+ regions: {
+ headerRegion: '#users-header',
+ searchRegion: '#users-search',
+ listRegion: '#users-list',
+ listFooterRegion: '#users-list-footer'
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/list-footer-view.js b/server/sonar-web/src/main/js/apps/users/list-footer-view.js
new file mode 100644
index 00000000000..968ad0990d5
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/list-footer-view.js
@@ -0,0 +1,34 @@
+define([
+ './templates'
+], function () {
+
+ return Marionette.ItemView.extend({
+ template: Templates['users-list-footer'],
+
+ collectionEvents: {
+ 'all': 'render'
+ },
+
+ events: {
+ 'click #users-fetch-more': 'onMoreClick'
+ },
+
+ onMoreClick: function (e) {
+ e.preventDefault();
+ this.fetchMore();
+ },
+
+ fetchMore: function () {
+ this.collection.fetchMore();
+ },
+
+ serializeData: function () {
+ return _.extend(Marionette.ItemView.prototype.serializeData.apply(this, arguments), {
+ total: this.collection.total,
+ count: this.collection.length,
+ more: this.collection.hasMore()
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/list-item-view.js b/server/sonar-web/src/main/js/apps/users/list-item-view.js
new file mode 100644
index 00000000000..0a3e62ace44
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/list-item-view.js
@@ -0,0 +1,86 @@
+define([
+ './update-view',
+ './change-password-view',
+ './deactivate-view',
+ './templates'
+], function (UpdateView, ChangePasswordView, DeactivateView) {
+
+ return Marionette.ItemView.extend({
+ tagName: 'li',
+ className: 'panel panel-vertical',
+ template: Templates['users-list-item'],
+
+ events: {
+ 'click .js-user-more-scm': 'onMoreScmClick',
+ 'click .js-user-update': 'onUpdateClick',
+ 'click .js-user-change-password': 'onChangePasswordClick',
+ 'click .js-user-deactivate': 'onDeactivateClick'
+ },
+
+ initialize: function () {
+ this.scmLimit = 3;
+ },
+
+ onRender: function () {
+ this.$el.attr('data-login', this.model.id);
+ this.$('[data-toggle="tooltip"]').tooltip({ container: 'body', placement: 'bottom' });
+ },
+
+ onClose: function () {
+ this.$('[data-toggle="tooltip"]').tooltip('destroy');
+ },
+
+ onMoreScmClick: function (e) {
+ e.preventDefault();
+ this.showMoreScm();
+ },
+
+ onUpdateClick: function (e) {
+ e.preventDefault();
+ this.updateUser();
+ },
+
+ onChangePasswordClick: function (e) {
+ e.preventDefault();
+ this.changePassword();
+ },
+
+ onDeactivateClick: function (e) {
+ e.preventDefault();
+ this.deactivateUser();
+ },
+
+ showMoreScm: function () {
+ this.scmLimit = 10000;
+ this.render();
+ },
+
+ updateUser: function () {
+ new UpdateView({
+ model: this.model,
+ collection: this.model.collection
+ }).render();
+ },
+
+ changePassword: function () {
+ new ChangePasswordView({
+ model: this.model,
+ collection: this.model.collection
+ }).render();
+ },
+
+ deactivateUser: function () {
+ new DeactivateView({ model: this.model }).render();
+ },
+
+ serializeData: function () {
+ var scmAccounts = this.model.get('scmAccounts'),
+ scmAccountsLimit = scmAccounts.length > this.scmLimit ? this.scmLimit - 1 : this.scmLimit;
+ return _.extend(Marionette.ItemView.prototype.serializeData.apply(this, arguments), {
+ firstScmAccounts: _.first(scmAccounts, scmAccountsLimit),
+ moreScmAccountsCount: scmAccounts.length - scmAccountsLimit
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/list-view.js b/server/sonar-web/src/main/js/apps/users/list-view.js
new file mode 100644
index 00000000000..138c36b7619
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/list-view.js
@@ -0,0 +1,11 @@
+define([
+ './list-item-view',
+ './templates'
+], function (ListItemView) {
+
+ return Marionette.CollectionView.extend({
+ tagName: 'ul',
+ itemView: ListItemView
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/search-view.js b/server/sonar-web/src/main/js/apps/users/search-view.js
new file mode 100644
index 00000000000..5129bf5a253
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/search-view.js
@@ -0,0 +1,49 @@
+define([
+ './templates'
+], function () {
+
+ return Marionette.ItemView.extend({
+ template: Templates['users-search'],
+
+ events: {
+ 'submit #users-search-form': 'onFormSubmit',
+ 'search #users-search-query': 'debouncedOnKeyUp',
+ 'keyup #users-search-query': 'debouncedOnKeyUp'
+ },
+
+ initialize: function () {
+ this._bufferedValue = null;
+ this.debouncedOnKeyUp = _.debounce(this.onKeyUp, 400);
+ },
+
+ onRender: function () {
+ this.delegateEvents();
+ },
+
+ onFormSubmit: function (e) {
+ e.preventDefault();
+ this.debouncedOnKeyUp();
+ },
+
+ onKeyUp: function () {
+ var q = this.getQuery();
+ if (q === this._bufferedValue) {
+ return;
+ }
+ this._bufferedValue = this.getQuery();
+ if (this.searchRequest != null) {
+ this.searchRequest.abort();
+ }
+ this.searchRequest = this.search(q);
+ },
+
+ getQuery: function () {
+ return this.$('#users-search-query').val();
+ },
+
+ search: function (q) {
+ return this.collection.fetch({ reset: true, data: { q: q } });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-change-password.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-change-password.hbs
new file mode 100644
index 00000000000..22684806543
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-change-password.hbs
@@ -0,0 +1,24 @@
+<form id="change-user-password-form" autocomplete="off">
+ <div class="modal-head">
+ <h2>Change Password</h2>
+ </div>
+ <div class="modal-body">
+ <div class="js-modal-messages"></div>
+ <div class="modal-field">
+ <label for="change-user-password-password">New Password<em class="mandatory">*</em></label>
+ {{! keep this fake field to hack browser autofill }}
+ <input id="change-user-password-password-fake" name="password-fake" type="password" class="hidden">
+ <input id="change-user-password-password" name="password" type="password" size="50" maxlength="50" required>
+ </div>
+ <div class="modal-field">
+ <label for="change-user-password-password-confirmation">Confirm Password<em class="mandatory">*</em></label>
+ {{! keep this fake field to hack browser autofill }}
+ <input id="change-user-password-password-confirmation-fake" name="password-confirmation-fake" type="password" class="hidden">
+ <input id="change-user-password-password-confirmation" name="password-confirmation" type="password" size="50" maxlength="50" required>
+ </div>
+ </div>
+ <div class="modal-foot">
+ <button id="change-user-password-submit">Change</button>
+ <a href="#" class="js-modal-close" id="change-user-password-cancel">Cancel</a>
+ </div>
+</form>
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-deactivate.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-deactivate.hbs
new file mode 100644
index 00000000000..4d92cfd6e72
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-deactivate.hbs
@@ -0,0 +1,13 @@
+<form id="deactivate-user-form" autocomplete="off">
+ <div class="modal-head">
+ <h2>Deactivate User</h2>
+ </div>
+ <div class="modal-body">
+ <div class="js-modal-messages"></div>
+ Are you sure you want to deactivate "{{name}} ({{login}})"?
+ </div>
+ <div class="modal-foot">
+ <button id="deactivate-user-submit">Deactivate</button>
+ <a href="#" class="js-modal-close" id="deactivate-user-cancel">Cancel</a>
+ </div>
+</form>
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-form.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-form.hbs
new file mode 100644
index 00000000000..f2637ce8108
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-form.hbs
@@ -0,0 +1,53 @@
+<form id="create-user-form" autocomplete="off">
+ <div class="modal-head">
+ <h2>{{#if login}}Update{{else}}Create{{/if}} User</h2>
+ </div>
+ <div class="modal-body">
+ <div class="js-modal-messages"></div>
+ {{#unless login}}
+ <div class="modal-field">
+ <label for="create-user-login">Login<em class="mandatory">*</em></label>
+ {{! keep this fake field to hack browser autofill }}
+ <input id="create-user-login-fake" name="login-fake" type="text" class="hidden">
+ <input id="create-user-login" name="login" type="text" size="50" minlength="2" maxlength="255" required
+ value="{{login}}">
+ <p class="note">Minimum 2 characters</p>
+ </div>
+ {{/unless}}
+ <div class="modal-field">
+ <label for="create-user-name">Name<em class="mandatory">*</em></label>
+ {{! keep this fake field to hack browser autofill }}
+ <input id="create-user-name-fake" name="name-fake" type="text" class="hidden">
+ <input id="create-user-name" name="name" type="text" size="50" maxlength="200" required value="{{name}}">
+ </div>
+ <div class="modal-field">
+ <label for="create-user-email">Email</label>
+ {{! keep this fake field to hack browser autofill }}
+ <input id="create-user-email-fake" name="email-fake" type="email" class="hidden">
+ <input id="create-user-email" name="email" type="email" size="50" maxlength="100" value="{{email}}">
+ </div>
+ {{#unless login}}
+ <div class="modal-field">
+ <label for="create-user-password">Password<em class="mandatory">*</em></label>
+ {{! keep this fake field to hack browser autofill }}
+ <input id="create-user-password-fake" name="password-fake" type="password" class="hidden">
+ <input id="create-user-password" name="password" type="password" size="50" maxlength="50" required>
+ </div>
+ {{/unless}}
+ <div class="modal-field">
+ <label>SCM Accounts</label>
+ {{#each scmAccounts}}
+ <input name="scmAccounts" type="text" size="50" maxlength="255" value="{{this}}">
+ {{else}}
+ <input name="scmAccounts" type="text" size="50" maxlength="255">
+ {{/each}}
+ <a id="create-user-add-scm-account" class="icon-plus" href="#" title="Add another SCM account"
+ data-toggle="tooltip"></a>
+ <p class="note">Note that login and email are automatically considered as SCM accounts</p>
+ </div>
+ </div>
+ <div class="modal-foot">
+ <button id="create-user-submit">{{#if login}}Update{{else}}Create{{/if}}</button>
+ <a href="#" class="js-modal-close" id="create-user-cancel">Cancel</a>
+ </div>
+</form>
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-header.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-header.hbs
new file mode 100644
index 00000000000..e3560039288
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-header.hbs
@@ -0,0 +1,9 @@
+<header class="page-header">
+ <h1 class="page-title">{{t 'users.page'}}</h1>
+ <div class="page-actions">
+ <div class="button-group">
+ <button id="users-create">Create User</button>
+ </div>
+ </div>
+ <p class="page-description">{{t 'users.page.description'}}</p>
+</header>
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-layout.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-layout.hbs
new file mode 100644
index 00000000000..e89130ed737
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-layout.hbs
@@ -0,0 +1,6 @@
+<div class="page">
+ <div id="users-header"></div>
+ <div id="users-search"></div>
+ <div id="users-list"></div>
+ <div id="users-list-footer"></div>
+</div>
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-list-footer.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-list-footer.hbs
new file mode 100644
index 00000000000..3cf34d7be8f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-list-footer.hbs
@@ -0,0 +1,6 @@
+<footer class="spacer-top note text-center">
+ {{count}}/{{total}} shown
+ {{#if more}}
+ <a id="users-fetch-more" class="spacer-left" href="#">show more</a>
+ {{/if}}
+</footer>
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs
new file mode 100644
index 00000000000..ce816033e05
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs
@@ -0,0 +1,46 @@
+<div class="pull-right big-spacer-left nowrap">
+ <a class="js-user-update icon-edit little-spacer-right" title="Update Details" data-toggle="tooltip" href="#"></a>
+ <a class="js-user-change-password icon-lock little-spacer-right" title="Change Password" data-toggle="tooltip"
+ href="#"></a>
+ <a class="js-user-deactivate icon-delete" title="Deactivate" data-toggle="tooltip" href="#"></a>
+</div>
+
+<div class="display-inline-block text-top width-30">
+ <div>
+ <strong class="js-user-name">{{name}}</strong>
+ <span class="js-user-login note little-spacer-left">{{login}}</span>
+ </div>
+ <div class="js-user-email little-spacer-top">{{email}}</div>
+</div>
+
+<div class="display-inline-block text-top big-spacer-left width-25">
+ {{#notEmpty scmAccounts}}
+ <div class="pull-left spacer-right">
+ <strong>SCM</strong>
+ </div>
+ <ul class="overflow-hidden bordered-left">
+ {{#each firstScmAccounts}}
+ <li class="spacer-left little-spacer-bottom">{{this}}</li>
+ {{/each}}
+ {{#gt moreScmAccountsCount 0}}
+ <li class="spacer-left little-spacer-bottom">
+ <a class="js-user-more-scm" href="#">{{moreScmAccountsCount}} more</a>
+ </li>
+ {{/gt}}
+ </ul>
+ {{/notEmpty}}
+</div>
+
+<div class="display-inline-block text-top big-spacer-left width-25">
+ <div class="pull-left spacer-right">
+ <strong>Groups</strong>
+ </div>
+ <ul class="overflow-hidden bordered-left">
+ <li class="spacer-left little-spacer-bottom">sonar-users ?</li>
+ <li class="spacer-left little-spacer-bottom">sonar-administrators ?</li>
+ <li class="spacer-left little-spacer-bottom">
+ <a class="js-user-more-groups" href="#">? more</a>
+ <a class="icon-bullet-list spacer-left" title="Update Groups" data-toggle="tooltip" href="#"></a>
+ </li>
+ </ul>
+</div>
diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-search.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-search.hbs
new file mode 100644
index 00000000000..38805050b80
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/templates/users-search.hbs
@@ -0,0 +1,6 @@
+<div class="panel panel-vertical bordered-bottom spacer-bottom">
+ <form id="users-search-form" class="search-box">
+ <button id="users-search-submit" class="search-box-submit button-clean"><i class="icon-search"></i></button>
+ <input id="users-search-query" class="search-box-input" type="search" name="q" placeholder="Search" maxlength="100">
+ </form>
+</div>
diff --git a/server/sonar-web/src/main/js/apps/users/update-view.js b/server/sonar-web/src/main/js/apps/users/update-view.js
new file mode 100644
index 00000000000..81497a3a75d
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/update-view.js
@@ -0,0 +1,30 @@
+define([
+ './form-view'
+], function (FormView) {
+
+ return FormView.extend({
+
+ sendRequest: function () {
+ var that = this;
+ this.model.set({
+ name: this.$('#create-user-name').val(),
+ email: this.$('#create-user-email').val(),
+ scmAccounts: this.getScmAccounts()
+ });
+ this.disableForm();
+ return this.model.save(null, {
+ statusCode: {
+ // do not show global error
+ 400: null
+ }
+ }).done(function () {
+ that.collection.refresh();
+ that.close();
+ }).fail(function (jqXHR) {
+ that.enableForm();
+ that.showErrors(jqXHR.responseJSON.errors, jqXHR.responseJSON.warnings);
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/user.js b/server/sonar-web/src/main/js/apps/users/user.js
new file mode 100644
index 00000000000..119a27fbfd5
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/user.js
@@ -0,0 +1,71 @@
+define(function () {
+
+ return Backbone.Model.extend({
+ idAttribute: 'login',
+
+ urlRoot: function () {
+ return baseUrl + '/api/users';
+ },
+
+ defaults: function () {
+ return {
+ groupsCount: 0,
+ scmAccounts: []
+ };
+ },
+
+ toQuery: function () {
+ var q = this.toJSON();
+ _.each(q, function (value, key) {
+ if (_.isArray(value)) {
+ q[key] = value.join(',');
+ }
+ });
+ return q;
+ },
+
+ isNew: function() {
+ // server never sends a password
+ return this.has('password');
+ },
+
+ sync: function (method, model, options) {
+ var opts = options || {};
+ if (method === 'create') {
+ _.defaults(opts, {
+ url: this.urlRoot() + '/create',
+ type: 'POST',
+ data: _.pick(model.toQuery(), 'login', 'name', 'email', 'password', 'scmAccounts')
+ });
+ }
+ if (method === 'update') {
+ _.defaults(opts, {
+ url: this.urlRoot() + '/update',
+ type: 'POST',
+ data: _.pick(model.toQuery(), 'login', 'name', 'email', 'scmAccounts')
+ });
+ }
+ if (method === 'delete') {
+ _.defaults(opts, {
+ url: this.urlRoot() + '/deactivate',
+ type: 'POST',
+ data: { login: this.id }
+ });
+ }
+ return Backbone.ajax(opts);
+ },
+
+ changePassword: function (password, options) {
+ var opts = _.defaults(options || {}, {
+ url: this.urlRoot() + '/change_password',
+ type: 'POST',
+ data: {
+ login: this.id,
+ password: password
+ }
+ });
+ return Backbone.ajax(opts);
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/users.js b/server/sonar-web/src/main/js/apps/users/users.js
new file mode 100644
index 00000000000..adf80b669da
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/users.js
@@ -0,0 +1,40 @@
+define([
+ './user'
+], function (User) {
+
+ return Backbone.Collection.extend({
+ model: User,
+
+ url: function () {
+ return baseUrl + '/api/users/search';
+ },
+
+ parse: function (r) {
+ this.total = +r.total;
+ this.p = +r.p;
+ this.ps = +r.ps;
+ return r.users;
+ },
+
+ fetch: function (options) {
+ var d = (options && options.data) || {};
+ this.q = d.q;
+ return Backbone.Collection.prototype.fetch.apply(this, arguments);
+ },
+
+ fetchMore: function () {
+ var p = this.p + 1;
+ return this.fetch({ add: true, remove: false, data: { p: p, ps: this.ps, q: this.q } });
+ },
+
+ refresh: function () {
+ return this.fetch({ reset: true, data: { q: this.q } });
+ },
+
+ hasMore: function () {
+ return this.total > this.p * this.ps;
+ }
+
+ });
+
+});
diff --git a/server/sonar-web/src/main/less/components.less b/server/sonar-web/src/main/less/components.less
index 58c47a924de..929acdc7e74 100644
--- a/server/sonar-web/src/main/less/components.less
+++ b/server/sonar-web/src/main/less/components.less
@@ -42,3 +42,4 @@
@import "components/badges";
@import "components/columns";
@import "components/workspace";
+@import "components/search";
diff --git a/server/sonar-web/src/main/less/components/modals.less b/server/sonar-web/src/main/less/components/modals.less
index 98fd4a4af1e..49863dbcfcc 100644
--- a/server/sonar-web/src/main/less/components/modals.less
+++ b/server/sonar-web/src/main/less/components/modals.less
@@ -143,6 +143,7 @@ ul.modal-head-metadata li {
}
.modal-field input[type=text],
+.modal-field input[type=email],
.modal-field input[type=password],
.modal-field textarea {
width: 250px;
diff --git a/server/sonar-web/src/main/less/components/search.less b/server/sonar-web/src/main/less/components/search.less
new file mode 100644
index 00000000000..48be2ff2905
--- /dev/null
+++ b/server/sonar-web/src/main/less/components/search.less
@@ -0,0 +1,24 @@
+@import (reference) "../variables";
+@import (reference) "../mixins";
+
+.search-box {
+ position: relative;
+ font-size: 0;
+}
+
+.search-box-input {
+ vertical-align: middle;
+ width: 250px;
+ border: none !important;
+ font-size: @baseFontSize;
+}
+
+.search-box-submit {
+ display: inline-block;
+ vertical-align: middle;
+
+ .icon-search:before {
+ color: @secondFontColor;
+ font-size: @iconSmallFontSize;
+ }
+}
diff --git a/server/sonar-web/src/main/less/init/icons.less b/server/sonar-web/src/main/less/init/icons.less
index fe27ce965a9..e94718f0f4f 100644
--- a/server/sonar-web/src/main/less/init/icons.less
+++ b/server/sonar-web/src/main/less/init/icons.less
@@ -310,6 +310,7 @@ a[class^="icon-"], a[class*=" icon-"] {
}
.icon-bullet-list:before {
content: "\f03a";
+ font-size: @iconSmallFontSize;
}
.icon-settings:before {
content: "\f015";
@@ -586,6 +587,10 @@ a[class^="icon-"], a[class*=" icon-"] {
content: "\f0b0";
font-size: @iconFontSize;
}
+.icon-lock:before {
+ content: "\f023";
+ font-size: @iconFontSize;
+}
.icon-issues {
display: inline-block;
.square(60px);
diff --git a/server/sonar-web/src/main/less/init/misc.less b/server/sonar-web/src/main/less/init/misc.less
index b766304822a..3cc8526e54d 100644
--- a/server/sonar-web/src/main/less/init/misc.less
+++ b/server/sonar-web/src/main/less/init/misc.less
@@ -43,6 +43,11 @@
.spacer-bottom { margin-bottom: 8px; }
.spacer-top { margin-top: 8px; }
+.big-spacer-left { margin-left: 16px; }
+.big-spacer-right { margin-right: 16px; }
+.big-spacer-bottom { margin-bottom: 16px; }
+.big-spacer-top { margin-top: 16px; }
+
.little-spacer-left { margin-left: 4px; }
.little-spacer-right { margin-right: 4px; }
.little-spacer-bottom { margin-bottom: 4px; }
@@ -62,12 +67,16 @@ td.spacer-top { padding-top: 8px; }
.bordered-bottom { border-bottom: 1px solid @barBorderColor; }
.bordered-top { border-top: 1px solid @barBorderColor; }
+.overflow-hidden { overflow: hidden; }
+
.width-100 { width: 100%; }
.width-80 { width: 80%; }
.width-60 { width: 60%; }
.width-55 { width: 55%; }
.width-50 { width: 50%; }
.width-40 { width: 40%; }
+.width-30 { width: 30%; }
+.width-25 { width: 25%; }
.width-20 { width: 20%; }
.width-15 { width: 15%; }
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/users_controller.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/users_controller.rb
index 779a828b329..ac643f9c0d4 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/users_controller.rb
+++ b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/users_controller.rb
@@ -54,98 +54,15 @@ class UsersController < ApplicationController
end
def index
- init_users_list
- if params[:id]
- @user = User.find(params[:id])
- else
- @user = User.new
- end
- end
- def create_form
- @user = User.new
- render :partial => 'users/create_form'
end
def new
render :action => 'new', :layout => 'nonav'
end
- def edit_form
- call_backend do
- @user = Internal.users_api.getByLogin(params[:id])
- render :partial => 'users/edit_form', :status => 200
- end
- end
-
- def change_password_form
- @user = User.find(params[:id])
- render :partial => 'users/change_password_form', :status => 200
- end
-
- def update_password
- user = User.find(params[:id])
- @user = user
- if params[:user][:password].blank?
- @errors = message('my_profile.password.empty')
- render :partial => 'users/change_password_form', :status => 400
- elsif user.update_attributes(:password => params[:user][:password], :password_confirmation => params[:user][:password_confirmation])
- flash[:notice] = 'Password was successfully updated.'
- render :text => 'ok', :status => 200
- else
- @errors = user.errors.full_messages.join("<br/>\n")
- render :partial => 'users/change_password_form', :status => 400
- end
- end
-
- def update
- call_backend do
- Internal.users_api.update(params[:user])
- flash[:notice] = 'User was successfully updated.'
- render :text => 'ok', :status => 200
- end
- end
-
- def delete
- begin
- user = User.find(params[:id])
- Api.users.deactivate(user.login)
- flash[:notice] = 'User is deleted.'
- rescue NativeException => exception
- if exception.cause.java_kind_of? Java::OrgSonarServerExceptions::ServerException
- error = exception.cause
- flash[:error] = (error.getMessage ? error.getMessage : Api::Utils.message(error.l10nKey, :params => error.l10nParams.to_a))
- else
- flash[:error] = 'Error when deleting this user.'
- end
- end
-
- redirect_to(:action => 'index', :id => nil)
- end
-
- def select_group
- @user = User.find(params[:id])
- render :partial => 'users/select_group'
- end
-
- def set_groups
- @user = User.find(params[:id])
-
- if @user.set_groups(params[:groups])
- flash[:notice] = 'User is updated.'
- end
-
- redirect_to(:action => 'index')
- end
-
- def to_index(errors, id)
- if !errors.empty?
- flash[:error] = errors.full_messages.join("<br/>\n")
- end
-
- redirect_to(:action => 'index', :id => id)
- end
+ private
def prepare_user
user = User.new(params[:user])
@@ -155,11 +72,4 @@ class UsersController < ApplicationController
user
end
-
- private
-
- def init_users_list
- @users = User.find(:all, :conditions => ["active=?", true], :include => 'groups')
- end
-
end
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/views/users/index.html.erb b/server/sonar-web/src/main/webapp/WEB-INF/app/views/users/index.html.erb
index 59ac4c82243..e3026f52084 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/app/views/users/index.html.erb
+++ b/server/sonar-web/src/main/webapp/WEB-INF/app/views/users/index.html.erb
@@ -1,63 +1,6 @@
-<% content_for :script do %>
- <script>require(['components/common/select-list']);</script>
-<% end %>
-
-<div class="page">
- <header class="page-header">
- <h1 class="page-title"><%= message('users.page') -%></h1>
- <% if profiles_administrator? %>
- <div class="page-actions">
- <a id="create-link-user" href="<%= ApplicationController.root_context -%>/users/create_form" class="open-modal">
- Add new user
- </a>
- </div>
- <% end %>
- <p class="page-description"><%= message('users.page.description') -%> </p>
- </header>
-
- <table width="100%">
- <tr>
- <td valign="top">
- <table class="data width100 sortable" id="users">
- <thead>
- <tr>
- <th class="text-left"><a>Login</a></th>
- <th class="text-left sortfirstasc"><a>Name</a></th>
- <th class="text-left"><a>Email</a></th>
- <th class="text-left nosort"><a>Groups</a></th>
- <th class="text-right nosort" nowrap><a>Operations</a></th>
- </tr>
- </thead>
- <tbody>
- <% @users.each do |user| %>
- <tr id="user-<%= user.login.parameterize -%>">
- <td class="text-left" valign="top"><%= h user.login -%></td>
- <td class="text-left" valign="top"><%= h user.name -%></td>
- <td class="text-left" valign="top"><%= h user.email -%></td>
- <td class="text-left" valign="top">
- <%= h user.groups.sort.map(&:name).join(', ') %>
- (<%= link_to "select", {:action => 'select_group', :id => user.id}, {:id => "select-#{user.login.parameterize}", :class => 'open-modal link-action'} %>)
- </td>
- <td class="text-right" valign="top">
- <a id="edit-<%= user.login -%>" class="open-modal link-action" href="<%= ApplicationController.root_context -%>/users/edit_form/<%= u user.login -%>">Edit</a>
- &nbsp;
- <%= link_to 'Change password', {:id => user.id, :action => 'change_password_form'}, {:id => "change-password-#{user.login.parameterize}", :class => 'open-modal link-action'} -%>
- &nbsp;
- <%= link_to_action message('delete'), "#{ApplicationController.root_context}/users/delete/#{user.id}",
- :class => 'link-action link-red',
- :id => "delete-#{user.login}",
- :confirm_button => message('delete'),
- :confirm_title => 'Delete user: '+user.login,
- :confirm_msg => 'Warning : are you sure to delete the user "' + user.name+'"?',
- :confirm_msg_params => [user.name]
- -%>
- </td>
- </tr>
- <% end %>
- </tbody>
- </table>
- <script>jQuery('#users').sortable();</script>
- </td>
- </tr>
- </table>
-</div>
+<div id="users"></div>
+<script>
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+</script>
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb b/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb
index 6f17f4f61b0..2a60362d112 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb
+++ b/server/sonar-web/src/main/webapp/WEB-INF/config/routes.rb
@@ -1,10 +1,4 @@
ActionController::Routing::Routes.draw do |map|
- map.connect 'users/select_group', :controller => 'users', :action => 'select_group'
- map.connect 'users/set_groups', :controller => 'users', :action => 'set_groups'
- map.connect 'users/create_form', :controller => 'users', :action => 'create_form'
- map.connect 'users/edit_form', :controller => 'users', :action => 'edit_form'
- map.resources :users
-
map.namespace :api do |api|
api.resources :events, :only => [:index, :show, :create, :destroy]
api.resources :user_properties, :only => [:index, :show, :create, :destroy], :requirements => { :id => /.*/ }
diff --git a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.eot b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.eot
index 2b2f832e8c8..fbc4d06cfa3 100755
--- a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.eot
+++ b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.eot
Binary files differ
diff --git a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.svg b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.svg
index 972c3f3ee0c..1d648b7b456 100755
--- a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.svg
+++ b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.svg
@@ -40,6 +40,7 @@
<glyph unicode="&#xf017;" d="M73.143 82.286h585.143v438.857h-237.714q-22.857 0-38.857 16t-16 38.857v237.714h-292.571v-731.429zM438.857 594.286h214.857q-5.714 16.571-12.571 23.429l-178.857 178.857q-6.857 6.857-23.429 12.571v-214.857zM731.429 576v-512q0-22.857-16-38.857t-38.857-16h-621.714q-22.857 0-38.857 16t-16 38.857v768q0 22.857 16 38.857t38.857 16h365.714q22.857 0 50.286-11.429t43.429-27.429l178.286-178.286q16-16 27.429-43.429t11.429-50.286z" horiz-adv-x="731" />
<glyph unicode="&#xf018;" d="M512 649.143v-256q0-8-5.143-13.143t-13.143-5.143h-182.857q-8 0-13.143 5.143t-5.143 13.143v36.571q0 8 5.143 13.143t13.143 5.143h128v201.143q0 8 5.143 13.143t13.143 5.143h36.571q8 0 13.143-5.143t5.143-13.143zM749.714 448q0 84.571-41.714 156t-113.143 113.143-156 41.714-156-41.714-113.143-113.143-41.714-156 41.714-156 113.143-113.143 156-41.714 156 41.714 113.143 113.143 41.714 156zM877.714 448q0-119.429-58.857-220.286t-159.714-159.714-220.286-58.857-220.286 58.857-159.714 159.714-58.857 220.286 58.857 220.286 159.714 159.714 220.286 58.857 220.286-58.857 159.714-159.714 58.857-220.286z" />
<glyph unicode="&#xf021;" d="M863.429 356.571q0-2.857-0.571-4-36.571-153.143-153.143-248.286t-273.143-95.143q-83.429 0-161.429 31.429t-139.143 89.714l-73.714-73.714q-10.857-10.857-25.714-10.857t-25.714 10.857-10.857 25.714v256q0 14.857 10.857 25.714t25.714 10.857h256q14.857 0 25.714-10.857t10.857-25.714-10.857-25.714l-78.286-78.286q40.571-37.714 92-58.286t106.857-20.571q76.571 0 142.857 37.143t106.286 102.286q6.286 9.714 30.286 66.857 4.571 13.143 17.143 13.143h109.714q7.429 0 12.857-5.429t5.429-12.857zM877.714 813.714v-256q0-14.857-10.857-25.714t-25.714-10.857h-256q-14.857 0-25.714 10.857t-10.857 25.714 10.857 25.714l78.857 78.857q-84.571 78.286-199.429 78.286-76.571 0-142.857-37.143t-106.286-102.286q-6.286-9.714-30.286-66.857-4.571-13.143-17.143-13.143h-113.714q-7.429 0-12.857 5.429t-5.429 12.857v4q37.143 153.143 154.286 248.286t274.286 95.143q83.429 0 162.286-31.714t140-89.429l74.286 73.714q10.857 10.857 25.714 10.857t25.714-10.857 10.857-25.714z" />
+<glyph unicode="&#xf023;" d="M182.857 512h292.571v109.714q0 60.571-42.857 103.429t-103.429 42.857-103.429-42.857-42.857-103.429v-109.714zM658.286 457.143v-329.143q0-22.857-16-38.857t-38.857-16h-548.571q-22.857 0-38.857 16t-16 38.857v329.143q0 22.857 16 38.857t38.857 16h18.286v109.714q0 105.143 75.429 180.571t180.571 75.429 180.571-75.429 75.429-180.571v-109.714h18.286q22.857 0 38.857-16t16-38.857z" horiz-adv-x="658" />
<glyph unicode="&#xf02c;" d="M256 704q0 30.286-21.429 51.714t-51.714 21.429-51.714-21.429-21.429-51.714 21.429-51.714 51.714-21.429 51.714 21.429 21.429 51.714zM865.714 374.857q0-30.286-21.143-51.429l-280.571-281.143q-22.286-21.143-52-21.143-30.286 0-51.429 21.143l-408.571 409.143q-21.714 21.143-36.857 57.714t-15.143 66.857v237.714q0 29.714 21.714 51.429t51.429 21.714h237.714q30.286 0 66.857-15.143t58.286-36.857l408.571-408q21.143-22.286 21.143-52zM1085.143 374.857q0-30.286-21.143-51.429l-280.571-281.143q-22.286-21.143-52-21.143-20.571 0-33.714 8t-30.286 25.714l268.571 268.571q21.143 21.143 21.143 51.429 0 29.714-21.143 52l-408.571 408q-21.714 21.714-58.286 36.857t-66.857 15.143h128q30.286 0 66.857-15.143t58.286-36.857l408.571-408q21.143-22.286 21.143-52z" horiz-adv-x="1097" />
<glyph unicode="&#xf039;" d="M1024 192v-73.143q0-14.857-10.857-25.714t-25.714-10.857h-950.857q-14.857 0-25.714 10.857t-10.857 25.714v73.143q0 14.857 10.857 25.714t25.714 10.857h950.857q14.857 0 25.714-10.857t10.857-25.714zM1024 411.429v-73.143q0-14.857-10.857-25.714t-25.714-10.857h-950.857q-14.857 0-25.714 10.857t-10.857 25.714v73.143q0 14.857 10.857 25.714t25.714 10.857h950.857q14.857 0 25.714-10.857t10.857-25.714zM1024 630.857v-73.143q0-14.857-10.857-25.714t-25.714-10.857h-950.857q-14.857 0-25.714 10.857t-10.857 25.714v73.143q0 14.857 10.857 25.714t25.714 10.857h950.857q14.857 0 25.714-10.857t10.857-25.714zM1024 850.286v-73.143q0-14.857-10.857-25.714t-25.714-10.857h-950.857q-14.857 0-25.714 10.857t-10.857 25.714v73.143q0 14.857 10.857 25.714t25.714 10.857h950.857q14.857 0 25.714-10.857t10.857-25.714z" />
<glyph unicode="&#xf03a;" d="M146.286 210.286v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-109.714q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h109.714q7.429 0 12.857-5.429t5.429-12.857zM146.286 429.714v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-109.714q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h109.714q7.429 0 12.857-5.429t5.429-12.857zM146.286 649.143v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-109.714q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h109.714q7.429 0 12.857-5.429t5.429-12.857zM1024 210.286v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-768q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h768q7.429 0 12.857-5.429t5.429-12.857zM146.286 868.571v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-109.714q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h109.714q7.429 0 12.857-5.429t5.429-12.857zM1024 429.714v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-768q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h768q7.429 0 12.857-5.429t5.429-12.857zM1024 649.143v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-768q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h768q7.429 0 12.857-5.429t5.429-12.857zM1024 868.571v-109.714q0-7.429-5.429-12.857t-12.857-5.429h-768q-7.429 0-12.857 5.429t-5.429 12.857v109.714q0 7.429 5.429 12.857t12.857 5.429h768q7.429 0 12.857-5.429t5.429-12.857z" />
@@ -62,6 +63,7 @@
<glyph unicode="&#xf069;" d="M846.857 360q26.286-14.857 34-44.286t-7.143-55.714l-36.571-62.857q-14.857-26.286-44.286-34t-55.714 7.143l-152 87.429v-175.429q0-29.714-21.714-51.429t-51.429-21.714h-73.143q-29.714 0-51.429 21.714t-21.714 51.429v175.429l-152-87.429q-26.286-14.857-55.714-7.143t-44.286 34l-36.571 62.857q-14.857 26.286-7.143 55.714t34 44.286l152 88-152 88q-26.286 14.857-34 44.286t7.143 55.714l36.571 62.857q14.857 26.286 44.286 34t55.714-7.143l152-87.429v175.429q0 29.714 21.714 51.429t51.429 21.714h73.143q29.714 0 51.429-21.714t21.714-51.429v-175.429l152 87.429q26.286 14.857 55.714 7.143t44.286-34l36.571-62.857q14.857-26.286 7.143-55.714t-34-44.286l-152-88z" horiz-adv-x="951" />
<glyph unicode="&#xf073;" d="M73.143 9.143h164.571v164.571h-164.571v-164.571zM274.286 9.143h182.857v164.571h-182.857v-164.571zM73.143 210.286h164.571v182.857h-164.571v-182.857zM274.286 210.286h182.857v182.857h-182.857v-182.857zM73.143 429.714h164.571v164.571h-164.571v-164.571zM493.714 9.143h182.857v164.571h-182.857v-164.571zM274.286 429.714h182.857v164.571h-182.857v-164.571zM713.143 9.143h164.571v164.571h-164.571v-164.571zM493.714 210.286h182.857v182.857h-182.857v-182.857zM292.571 704v164.571q0 7.429-5.429 12.857t-12.857 5.429h-36.571q-7.429 0-12.857-5.429t-5.429-12.857v-164.571q0-7.429 5.429-12.857t12.857-5.429h36.571q7.429 0 12.857 5.429t5.429 12.857zM713.143 210.286h164.571v182.857h-164.571v-182.857zM493.714 429.714h182.857v164.571h-182.857v-164.571zM713.143 429.714h164.571v164.571h-164.571v-164.571zM731.429 704v164.571q0 7.429-5.429 12.857t-12.857 5.429h-36.571q-7.429 0-12.857-5.429t-5.429-12.857v-164.571q0-7.429 5.429-12.857t12.857-5.429h36.571q7.429 0 12.857 5.429t5.429 12.857zM950.857 740.571v-731.429q0-29.714-21.714-51.429t-51.429-21.714h-804.571q-29.714 0-51.429 21.714t-21.714 51.429v731.429q0 29.714 21.714 51.429t51.429 21.714h73.143v54.857q0 37.714 26.857 64.571t64.571 26.857h36.571q37.714 0 64.571-26.857t26.857-64.571v-54.857h219.429v54.857q0 37.714 26.857 64.571t64.571 26.857h36.571q37.714 0 64.571-26.857t26.857-64.571v-54.857h73.143q29.714 0 51.429-21.714t21.714-51.429z" horiz-adv-x="951" />
<glyph unicode="&#xf075;" d="M1024 448q0-99.429-68.571-183.714t-186.286-133.143-257.143-48.857q-40 0-82.857 4.571-113.143-100-262.857-138.286-28-8-65.143-12.571-9.714-1.143-17.429 5.143t-10 16.571v0.571q-1.714 2.286-0.286 6.857t1.143 5.714 2.571 5.429l3.429 5.143t4 4.857 4.571 5.143q4 4.571 17.714 19.714t19.714 21.714 17.714 22.571 18.571 29.143 15.429 33.714 14.857 43.429q-89.714 50.857-141.429 125.714t-51.714 160.571q0 74.286 40.571 142t109.143 116.857 163.429 78 198.857 28.857q139.429 0 257.143-48.857t186.286-133.143 68.571-183.714z" />
+<glyph unicode="&#xf080;" d="M365.714 438.857v-292.571h-146.286v292.571h146.286zM585.143 731.428v-585.143h-146.286v585.143h146.286zM1170.286 73.143v-73.143h-1170.286v877.714h73.143v-804.571h1097.143zM804.571 585.143v-438.857h-146.286v438.857h146.286zM1024 804.571v-658.286h-146.286v658.286h146.286z" horiz-adv-x="1170" />
<glyph unicode="&#xf085;" d="M512 448q0 60.571-42.857 103.429t-103.429 42.857-103.429-42.857-42.857-103.429 42.857-103.429 103.429-42.857 103.429 42.857 42.857 103.429zM950.857 155.429q0 29.714-21.714 51.429t-51.429 21.714-51.429-21.714-21.714-51.429q0-30.286 21.429-51.714t51.714-21.429 51.714 21.429 21.429 51.714zM950.857 740.571q0 29.714-21.714 51.429t-51.429 21.714-51.429-21.714-21.714-51.429q0-30.286 21.429-51.714t51.714-21.429 51.714 21.429 21.429 51.714zM731.429 500v-105.714q0-5.714-4-11.143t-9.143-6l-88.571-13.714q-6.286-20-18.286-43.429 19.429-27.429 51.429-65.714 4-5.714 4-11.429 0-6.857-4-10.857-13.143-17.143-47.143-51.143t-44.857-34q-6.286 0-12 4l-65.714 51.429q-21.143-10.857-44-17.714-6.286-61.714-13.143-88.571-4-13.714-17.143-13.714h-106.286q-6.286 0-11.429 4.286t-5.714 10l-13.143 87.429q-19.429 5.714-42.857 17.714l-67.429-50.857q-4-4-11.429-4-6.286 0-12 4.571-82.286 76-82.286 91.429 0 5.143 4 10.857 5.714 8 23.429 30.286t26.857 34.857q-13.143 25.143-20 46.857l-86.857 13.714q-5.714 0.571-9.714 5.429t-4 11.143v105.714q0 5.714 4 11.143t9.143 6l88.571 13.714q6.286 20 18.286 43.429-19.429 27.429-51.429 65.714-4 6.286-4 11.429 0 6.857 4 11.429 12.571 17.143 46.857 50.857t45.143 33.714q6.286 0 12-4l65.714-51.429q19.429 10.286 44 18.286 6.286 61.714 13.143 88 4 13.714 17.143 13.714h106.286q6.286 0 11.429-4.286t5.714-10l13.143-87.429q19.429-5.714 42.857-17.714l67.429 50.857q4.571 4 11.429 4 6.286 0 12-4.571 82.286-76 82.286-91.429 0-5.143-4-10.857-6.857-9.143-24-30.857t-25.714-34.286q13.143-27.429 19.429-46.857l86.857-13.143q5.714-1.143 9.714-6t4-11.143zM1097.143 195.429v-80q0-9.143-85.143-17.714-6.857-15.429-17.143-29.714 29.143-64.571 29.143-78.857 0-2.286-2.286-4-69.714-40.571-70.857-40.571-4.571 0-26.286 26.857t-29.714 38.857q-11.429-1.143-17.143-1.143t-17.143 1.143q-8-12-29.714-38.857t-26.286-26.857q-1.143 0-70.857 40.571-2.286 1.714-2.286 4 0 14.286 29.143 78.857-10.286 14.286-17.143 29.714-85.143 8.571-85.143 17.714v80q0 9.143 85.143 17.714 7.429 16.571 17.143 29.714-29.143 64.571-29.143 78.857 0 2.286 2.286 4 2.286 1.143 20 11.429t33.714 19.429 17.143 9.143q4.571 0 26.286-26.571t29.714-38.571q11.429 1.143 17.143 1.143t17.143-1.143q29.143 40.571 52.571 64l3.429 1.143q2.286 0 70.857-40 2.286-1.714 2.286-4 0-14.286-29.143-78.857 9.714-13.143 17.143-29.714 85.143-8.571 85.143-17.714zM1097.143 780.571v-80q0-9.143-85.143-17.714-6.857-15.429-17.143-29.714 29.143-64.571 29.143-78.857 0-2.286-2.286-4-69.714-40.571-70.857-40.571-4.571 0-26.286 26.857t-29.714 38.857q-11.429-1.143-17.143-1.143t-17.143 1.143q-8-12-29.714-38.857t-26.286-26.857q-1.143 0-70.857 40.571-2.286 1.714-2.286 4 0 14.286 29.143 78.857-10.286 14.286-17.143 29.714-85.143 8.571-85.143 17.714v80q0 9.143 85.143 17.714 7.429 16.571 17.143 29.714-29.143 64.571-29.143 78.857 0 2.286 2.286 4 2.286 1.143 20 11.429t33.714 19.429 17.143 9.143q4.571 0 26.286-26.571t29.714-38.571q11.429 1.143 17.143 1.143t17.143-1.143q29.143 40.571 52.571 64l3.429 1.143q2.286 0 70.857-40 2.286-1.714 2.286-4 0-14.286-29.143-78.857 9.714-13.143 17.143-29.714 85.143-8.571 85.143-17.714z" horiz-adv-x="1097" />
<glyph unicode="&#xf08e;" d="M804.571 429.714v-182.857q0-68-48.286-116.286t-116.286-48.286h-475.429q-68 0-116.286 48.286t-48.286 116.286v475.429q0 68 48.286 116.286t116.286 48.286h402.286q8 0 13.143-5.143t5.143-13.143v-36.571q0-8-5.143-13.143t-13.143-5.143h-402.286q-37.714 0-64.571-26.857t-26.857-64.571v-475.429q0-37.714 26.857-64.571t64.571-26.857h475.429q37.714 0 64.571 26.857t26.857 64.571v182.857q0 8 5.143 13.143t13.143 5.143h36.571q8 0 13.143-5.143t5.143-13.143zM1024 923.429v-292.571q0-14.857-10.857-25.714t-25.714-10.857-25.714 10.857l-100.571 100.571-372.571-372.571q-5.714-5.714-13.143-5.714t-13.143 5.714l-65.143 65.143q-5.714 5.714-5.714 13.143t5.714 13.143l372.571 372.571-100.571 100.571q-10.857 10.857-10.857 25.714t10.857 25.714 25.714 10.857h292.571q14.857 0 25.714-10.857t10.857-25.714z" />
<glyph unicode="&#xf091;" d="M261.714 455.429q-42.286 92.571-42.286 212h-146.286v-54.857q0-44.571 54-92.571t134.571-64.571zM877.714 612.571v54.857h-146.286q0-119.429-42.286-212 80.571 16.571 134.571 64.571t54 92.571zM950.857 685.714v-73.143q0-40.571-23.714-81.714t-64-74.286-98.857-55.714-123.143-25.429q-24-30.857-54.286-54.286-21.714-19.429-30-41.429t-8.286-51.143q0-30.857 17.429-52t55.714-21.143q42.857 0 76.286-26t33.429-65.429v-36.571q0-8-5.143-13.143t-13.143-5.143h-475.429q-8 0-13.143 5.143t-5.143 13.143v36.571q0 39.429 33.429 65.429t76.286 26q38.286 0 55.714 21.143t17.429 52q0 29.143-8.286 51.143t-30 41.429q-30.286 23.429-54.286 54.286-64.571 2.857-123.143 25.429t-98.857 55.714-64 74.286-23.714 81.714v73.143q0 22.857 16 38.857t38.857 16h164.571v54.857q0 37.714 26.857 64.571t64.571 26.857h329.143q37.714 0 64.571-26.857t26.857-64.571v-54.857h164.571q22.857 0 38.857-16t16-38.857z" horiz-adv-x="951" />
diff --git a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.ttf b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.ttf
index d4e65cacf9d..fa26fd2b826 100755
--- a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.ttf
+++ b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.ttf
Binary files differ
diff --git a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.woff b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.woff
index ae53de1e82b..1e9e0d823fc 100755
--- a/server/sonar-web/src/main/webapp/fonts/sonar-5.2.woff
+++ b/server/sonar-web/src/main/webapp/fonts/sonar-5.2.woff
Binary files differ
diff --git a/server/sonar-web/src/test/js/users-spec.js b/server/sonar-web/src/test/js/users-spec.js
new file mode 100644
index 00000000000..1b963016acd
--- /dev/null
+++ b/server/sonar-web/src/test/js/users-spec.js
@@ -0,0 +1,406 @@
+/* globals casper: false */
+var lib = require('../lib'),
+ testName = lib.testName('Users');
+
+lib.initMessages();
+lib.changeWorkingDirectory('users-spec');
+lib.configureCasper();
+
+casper.test.begin(testName('List'), 11, function (test) {
+ casper
+ .start(lib.buildUrl('users'), function () {
+ lib.setDefaultViewport();
+ lib.mockRequestFromFile('/api/users/search', 'search.json');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ test.assertExists('#users-list ul');
+ test.assertElementCount('#users-list li[data-login]', 3);
+ test.assertSelectorContains('#users-list .js-user-login', 'smith');
+ test.assertSelectorContains('#users-list .js-user-name', 'Bob');
+ test.assertSelectorContains('#users-list .js-user-email', 'bob@example.com');
+ test.assertElementCount('#users-list .js-user-update', 3);
+ test.assertElementCount('#users-list .js-user-change-password', 3);
+ test.assertElementCount('#users-list .js-user-deactivate', 3);
+ test.assertSelectorContains('#users-list-footer', '3/3');
+ })
+
+ .then(function () {
+ test.assertSelectorDoesntContain('[data-login="ryan"]', 'another@example.com');
+ casper.click('[data-login="ryan"] .js-user-more-scm');
+ test.assertSelectorContains('[data-login="ryan"]', 'another@example.com');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Search'), 4, function (test) {
+ casper
+ .start(lib.buildUrl('users'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search.json');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ test.assertElementCount('#users-list li[data-login]', 3);
+ lib.clearRequestMock(this.searchMock);
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search-filtered.json', { data: { q: 'ryan' } });
+ casper.evaluate(function () {
+ jQuery('#users-search-query').val('ryan');
+ });
+ casper.click('#users-search-submit');
+ casper.waitForSelectorTextChange('#users-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#users-list li[data-login]', 1);
+ lib.clearRequestMock(this.searchMock);
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search.json');
+ casper.evaluate(function () {
+ jQuery('#users-search-query').val('');
+ });
+ casper.click('#users-search-submit');
+ casper.waitForSelectorTextChange('#users-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#users-list li[data-login]', 3);
+ test.assert(casper.evaluate(function () {
+ return jQuery('#users-search-query').val() === '';
+ }));
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Show More'), 4, function (test) {
+ casper
+ .start(lib.buildUrl('users'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search-big-1.json');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ test.assertElementCount('#users-list li[data-login]', 2);
+ test.assertSelectorContains('#users-list-footer', '2/3');
+ lib.clearRequestMock(this.searchMock);
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search-big-2.json', { data: { p: '2' } });
+ casper.click('#users-fetch-more');
+ casper.waitForSelectorTextChange('#users-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#users-list li[data-login]', 3);
+ test.assertSelectorContains('#users-list-footer', '3/3');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Create'), 5, function (test) {
+ casper
+ .start(lib.buildUrl('users'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search.json');
+ this.createMock = lib.mockRequestFromFile('/api/users/create', 'error.json', { status: 400 });
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+ jQuery.ajaxSetup({ dataType: 'json' });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ test.assertElementCount('#users-list li[data-login]', 3);
+ casper.click('#users-create');
+ casper.waitForSelector('#create-user-form');
+ })
+
+ .then(function () {
+ casper.click('#create-user-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ lib.clearRequestMock(this.searchMock);
+ lib.mockRequestFromFile('/api/users/search', 'search-created.json');
+ lib.clearRequestMock(this.createMock);
+ lib.mockRequest('/api/users/create', '{}',
+ { data: { login: 'login', name: 'name', email: 'email@example.com', scmAccounts: 'scm1,scm2' } });
+ casper.click('#create-user-add-scm-account');
+ casper.click('#create-user-add-scm-account');
+ casper.evaluate(function () {
+ jQuery('#create-user-login').val('login');
+ jQuery('#create-user-name').val('name');
+ jQuery('#create-user-email').val('email@example.com');
+ jQuery('[name="scmAccounts"]').first().val('scm1');
+ jQuery('[name="scmAccounts"]').last().val('scm2');
+ });
+ casper.click('#create-user-submit');
+ casper.waitForSelectorTextChange('#users-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#users-list li[data-login]', 4);
+ test.assertSelectorContains('#users-list .js-user-login', 'login');
+ test.assertSelectorContains('#users-list .js-user-name', 'name');
+ test.assertSelectorContains('#users-list .js-user-email', 'email@example.com');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Update'), 3, function (test) {
+ casper
+ .start(lib.buildUrl('users'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search.json');
+ this.updateMock = lib.mockRequestFromFile('/api/users/update', 'error.json', { status: 400 });
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+ jQuery.ajaxSetup({ dataType: 'json' });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ casper.click('[data-login="smith"] .js-user-update');
+ casper.waitForSelector('#create-user-form');
+ })
+
+ .then(function () {
+ casper.click('#create-user-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ lib.clearRequestMock(this.searchMock);
+ lib.mockRequestFromFile('/api/users/search', 'search-updated.json');
+ lib.clearRequestMock(this.updateMock);
+ lib.mockRequest('/api/users/update', '{}',
+ { data: { login: 'smith', name: 'Mike', email: 'mike@example.com', scmAccounts: 'scm5,scm6' } });
+ casper.click('#create-user-add-scm-account');
+ casper.evaluate(function () {
+ jQuery('#create-user-name').val('Mike');
+ jQuery('#create-user-email').val('mike@example.com');
+ jQuery('[name="scmAccounts"]').first().val('scm5');
+ jQuery('[name="scmAccounts"]').last().val('scm6');
+ });
+ casper.click('#create-user-submit');
+ casper.waitForText('Mike');
+ })
+
+ .then(function () {
+ test.assertSelectorContains('[data-login="smith"] .js-user-login', 'smith');
+ test.assertSelectorContains('[data-login="smith"] .js-user-name', 'Mike');
+ test.assertSelectorContains('[data-login="smith"] .js-user-email', 'mike@example.com');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Change Password'), 1, function (test) {
+ casper
+ .start(lib.buildUrl('users'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search.json');
+ this.updateMock = lib.mockRequestFromFile('/api/users/change_password', 'error.json', { status: 400 });
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+ jQuery.ajaxSetup({ dataType: 'json' });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ casper.click('[data-login="smith"] .js-user-change-password');
+ casper.waitForSelector('#change-user-password-form');
+ })
+
+ .then(function () {
+ casper.click('#change-user-password-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ casper.click('#change-user-password-cancel');
+ casper.waitWhileSelector('#change-user-password-form');
+ })
+
+ .then(function () {
+ casper.click('[data-login="smith"] .js-user-change-password');
+ casper.waitForSelector('#change-user-password-form');
+ })
+
+ .then(function () {
+ lib.clearRequestMock(this.updateMock);
+ lib.mockRequest('/api/users/change_password', '{}', { data: { login: 'smith', password: 'secret' } });
+ casper.evaluate(function () {
+ jQuery('#change-user-password-password').val('secret');
+ jQuery('#change-user-password-password-confirmation').val('another');
+ });
+ casper.click('#change-user-password-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ jQuery('#change-user-password-password').val('secret');
+ jQuery('#change-user-password-password-confirmation').val('secret');
+ });
+ casper.click('#change-user-password-submit');
+ casper.waitWhileSelector('#change-user-password-form');
+ })
+
+ .then(function () {
+ test.assert(true);
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Deactivate'), 1, function (test) {
+ casper
+ .start(lib.buildUrl('users'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/users/search', 'search.json');
+ this.updateMock = lib.mockRequestFromFile('/api/users/deactivate', 'error.json', { status: 400 });
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/users/app'], function (App) {
+ App.start({ el: '#users' });
+ });
+ jQuery.ajaxSetup({ dataType: 'json' });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ casper.click('[data-login="smith"] .js-user-deactivate');
+ casper.waitForSelector('#deactivate-user-form');
+ })
+
+ .then(function () {
+ casper.click('#deactivate-user-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ lib.clearRequestMock(this.updateMock);
+ lib.mockRequest('/api/users/deactivate', '{}', { data: { login: 'smith'} });
+ casper.click('#deactivate-user-submit');
+ casper.waitWhileSelector('[data-login="smith"]');
+ })
+
+ .then(function () {
+ test.assert(true);
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
diff --git a/server/sonar-web/src/test/json/users-spec/error.json b/server/sonar-web/src/test/json/users-spec/error.json
new file mode 100644
index 00000000000..dc1b261128c
--- /dev/null
+++ b/server/sonar-web/src/test/json/users-spec/error.json
@@ -0,0 +1,7 @@
+{
+ "errors": [
+ {
+ "msg": "Some error message"
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/users-spec/search-big-1.json b/server/sonar-web/src/test/json/users-spec/search-big-1.json
new file mode 100644
index 00000000000..5e3f9811177
--- /dev/null
+++ b/server/sonar-web/src/test/json/users-spec/search-big-1.json
@@ -0,0 +1,19 @@
+{
+ "total": 3,
+ "p": 1,
+ "ps": 2,
+ "users": [
+ {
+ "login": "admin",
+ "name": "Administrator",
+ "email": "admin@example.com",
+ "scmAccounts": []
+ },
+ {
+ "login": "smith",
+ "name": "Bob",
+ "email": "bob@example.com",
+ "scmAccounts": []
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/users-spec/search-big-2.json b/server/sonar-web/src/test/json/users-spec/search-big-2.json
new file mode 100644
index 00000000000..605d9c1d62f
--- /dev/null
+++ b/server/sonar-web/src/test/json/users-spec/search-big-2.json
@@ -0,0 +1,13 @@
+{
+ "total": 3,
+ "p": 2,
+ "ps": 2,
+ "users": [
+ {
+ "login": "ryan",
+ "name": "John",
+ "email": "john@example.com",
+ "scmAccounts": []
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/users-spec/search-created.json b/server/sonar-web/src/test/json/users-spec/search-created.json
new file mode 100644
index 00000000000..a4f1282ba5c
--- /dev/null
+++ b/server/sonar-web/src/test/json/users-spec/search-created.json
@@ -0,0 +1,34 @@
+{
+ "total": 4,
+ "p": 1,
+ "ps": 50,
+ "users": [
+ {
+ "login": "admin",
+ "name": "Administrator",
+ "email": "admin@example.com",
+ "scmAccounts": []
+ },
+ {
+ "login": "login",
+ "name": "name",
+ "email": "email@example.com",
+ "scmAccounts": [
+ "scm1",
+ "scm2"
+ ]
+ },
+ {
+ "login": "smith",
+ "name": "Bob",
+ "email": "bob@example.com",
+ "scmAccounts": []
+ },
+ {
+ "login": "ryan",
+ "name": "John",
+ "email": "john@example.com",
+ "scmAccounts": []
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/users-spec/search-filtered.json b/server/sonar-web/src/test/json/users-spec/search-filtered.json
new file mode 100644
index 00000000000..ac74e4b63a3
--- /dev/null
+++ b/server/sonar-web/src/test/json/users-spec/search-filtered.json
@@ -0,0 +1,13 @@
+{
+ "total": 1,
+ "p": 1,
+ "ps": 50,
+ "users": [
+ {
+ "login": "ryan",
+ "name": "John",
+ "email": "john@example.com",
+ "scmAccounts": []
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/users-spec/search-updated.json b/server/sonar-web/src/test/json/users-spec/search-updated.json
new file mode 100644
index 00000000000..294f7ed51ce
--- /dev/null
+++ b/server/sonar-web/src/test/json/users-spec/search-updated.json
@@ -0,0 +1,25 @@
+{
+ "total": 3,
+ "p": 1,
+ "ps": 50,
+ "users": [
+ {
+ "login": "admin",
+ "name": "Administrator",
+ "email": "admin@example.com",
+ "scmAccounts": []
+ },
+ {
+ "login": "smith",
+ "name": "Mike",
+ "email": "mike@example.com",
+ "scmAccounts": []
+ },
+ {
+ "login": "ryan",
+ "name": "John",
+ "email": "john@example.com",
+ "scmAccounts": []
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/users-spec/search.json b/server/sonar-web/src/test/json/users-spec/search.json
new file mode 100644
index 00000000000..abe8f1dfb34
--- /dev/null
+++ b/server/sonar-web/src/test/json/users-spec/search.json
@@ -0,0 +1,25 @@
+{
+ "total": 3,
+ "p": 1,
+ "ps": 50,
+ "users": [
+ {
+ "login": "admin",
+ "name": "Administrator",
+ "email": "admin@example.com",
+ "scmAccounts": []
+ },
+ {
+ "login": "smith",
+ "name": "Bob",
+ "email": "bob@example.com",
+ "scmAccounts": ["smith@example.com"]
+ },
+ {
+ "login": "ryan",
+ "name": "John",
+ "email": "john@example.com",
+ "scmAccounts": ["ryan@example.com", "ryan", "john", "another@example.com"]
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/server-coverage.js b/server/sonar-web/src/test/server-coverage.js
index a6a46527f57..3fd4400cea3 100644
--- a/server/sonar-web/src/test/server-coverage.js
+++ b/server/sonar-web/src/test/server-coverage.js
@@ -24,7 +24,7 @@ var express = require('express'),
url = require('url'),
JS_RE = /\.js$/,
THIRD_PARTY_RE = /\/third-party\//,
- TEMPLATES_RE = /\/templates\//;
+ TEMPLATES_RE = /\/templates.js/;
var app = express();
diff --git a/server/sonar-web/src/test/views/users.jade b/server/sonar-web/src/test/views/users.jade
new file mode 100644
index 00000000000..d469a627868
--- /dev/null
+++ b/server/sonar-web/src/test/views/users.jade
@@ -0,0 +1,5 @@
+extends layouts/main
+
+block body
+ #content
+ #users