You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@guacamole.apache.org by vn...@apache.org on 2018/08/14 23:39:48 UTC

[10/13] guacamole-client git commit: GUACAMOLE-220: Add management tab and editor for user groups.

GUACAMOLE-220: Add management tab and editor for user groups.


Project: http://git-wip-us.apache.org/repos/asf/guacamole-client/repo
Commit: http://git-wip-us.apache.org/repos/asf/guacamole-client/commit/8ad3f253
Tree: http://git-wip-us.apache.org/repos/asf/guacamole-client/tree/8ad3f253
Diff: http://git-wip-us.apache.org/repos/asf/guacamole-client/diff/8ad3f253

Branch: refs/heads/master
Commit: 8ad3f2537119d61becad38558dc1365742ba7444
Parents: de80957
Author: Michael Jumper <mj...@apache.org>
Authored: Thu Apr 19 23:51:25 2018 -0700
Committer: Michael Jumper <mj...@apache.org>
Committed: Thu Aug 9 10:46:06 2018 -0700

----------------------------------------------------------------------
 .../webapp/app/index/config/indexRouteConfig.js |   9 +
 .../src/main/webapp/app/index/styles/lists.css  |   4 +
 .../src/main/webapp/app/index/styles/ui.css     |   8 +
 .../controllers/manageUserGroupController.js    | 538 +++++++++++++++++++
 .../manage/directives/systemPermissionEditor.js |   4 +
 .../app/manage/styles/manage-user-group.css     |  71 +++
 .../app/manage/templates/manageUserGroup.html   | 101 ++++
 .../app/manage/types/ManageableUserGroup.js     |  53 ++
 .../app/navigation/services/userPageService.js  |  27 +
 .../settings/controllers/settingsController.js  |   4 +-
 .../directives/guacSettingsUserGroups.js        | 270 ++++++++++
 .../main/webapp/app/settings/styles/buttons.css |   6 +
 .../app/settings/styles/user-group-list.css     |  36 ++
 .../webapp/app/settings/templates/settings.html |   1 +
 .../settings/templates/settingsUserGroups.html  |  48 ++
 .../images/action-icons/guac-user-group-add.png | Bin 0 -> 1222 bytes
 .../images/user-icons/guac-user-group.png       | Bin 0 -> 1428 bytes
 guacamole/src/main/webapp/translations/en.json  |  67 ++-
 18 files changed, 1244 insertions(+), 3 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js b/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
index 47bc48e..5a8c3fb 100644
--- a/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
+++ b/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
@@ -171,6 +171,15 @@ angular.module('index').config(['$routeProvider', '$locationProvider',
             resolve       : { updateCurrentToken: updateCurrentToken }
         })
 
+        // User group editor
+        .when('/manage/:dataSource/userGroups/:id?', {
+            title         : 'APP.NAME',
+            bodyClassName : 'manage',
+            templateUrl   : 'app/manage/templates/manageUserGroup.html',
+            controller    : 'manageUserGroupController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
         // Client view
         .when('/client/:id/:params?', {
             bodyClassName : 'client',

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/index/styles/lists.css
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/index/styles/lists.css b/guacamole/src/main/webapp/app/index/styles/lists.css
index 0c761ae..80df491 100644
--- a/guacamole/src/main/webapp/app/index/styles/lists.css
+++ b/guacamole/src/main/webapp/app/index/styles/lists.css
@@ -18,12 +18,14 @@
  */
 
 .user,
+.user-group,
 .connection-group,
 .connection {
     cursor: pointer;
 }
 
 .user a,
+.user-group a,
 .connection a,
 .connection-group a {
     text-decoration:none;
@@ -31,6 +33,7 @@
 }
 
 .user a:hover,
+.user-group a:hover,
 .connection a:hover,
 .connection-group a:hover {
     text-decoration:none;
@@ -38,6 +41,7 @@
 }
 
 .user a:visited,
+.user-group a:visited,
 .connection a:visited,
 .connection-group a:visited {
     text-decoration:none;

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/index/styles/ui.css
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/index/styles/ui.css b/guacamole/src/main/webapp/app/index/styles/ui.css
index 434f443..58406eb 100644
--- a/guacamole/src/main/webapp/app/index/styles/ui.css
+++ b/guacamole/src/main/webapp/app/index/styles/ui.css
@@ -156,6 +156,14 @@ div.section {
     background-image: url('images/action-icons/guac-user-add.png');
 }
 
+.icon.user-group {
+    background-image: url('images/user-icons/guac-user-group.png');
+}
+
+.icon.user-group.add {
+    background-image: url('images/action-icons/guac-user-group-add.png');
+}
+
 .icon.connection {
     background-image: url('images/protocol-icons/guac-plug.png');
 }

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/manage/controllers/manageUserGroupController.js
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/manage/controllers/manageUserGroupController.js b/guacamole/src/main/webapp/app/manage/controllers/manageUserGroupController.js
new file mode 100644
index 0000000..229b3b8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/controllers/manageUserGroupController.js
@@ -0,0 +1,538 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The controller for editing user groups.
+ */
+angular.module('manage').controller('manageUserGroupController', ['$scope', '$injector',
+        function manageUserGroupController($scope, $injector) {
+            
+    // Required types
+    var ManagementPermissions = $injector.get('ManagementPermissions');
+    var PermissionFlagSet     = $injector.get('PermissionFlagSet');
+    var PermissionSet         = $injector.get('PermissionSet');
+    var UserGroup             = $injector.get('UserGroup');
+
+    // Required services
+    var $location             = $injector.get('$location');
+    var $routeParams          = $injector.get('$routeParams');
+    var $q                    = $injector.get('$q');
+    var authenticationService = $injector.get('authenticationService');
+    var dataSourceService     = $injector.get('dataSourceService');
+    var membershipService     = $injector.get('membershipService');
+    var permissionService     = $injector.get('permissionService');
+    var requestService        = $injector.get('requestService');
+    var schemaService         = $injector.get('schemaService');
+    var userGroupService      = $injector.get('userGroupService');
+    var userService           = $injector.get('userService');
+
+    /**
+     * The identifiers of all data sources currently available to the
+     * authenticated user.
+     *
+     * @type String[]
+     */
+    var dataSources = authenticationService.getAvailableDataSources();
+
+    /**
+     * The username of the current, authenticated user.
+     *
+     * @type String
+     */
+    var currentUsername = authenticationService.getCurrentUsername();
+
+    /**
+     * The identifier of the original user group from which this user group is
+     * being cloned. Only valid if this is a new user group.
+     *
+     * @type String
+     */
+    var cloneSourceIdentifier = $location.search().clone;
+
+    /**
+     * The identifier of the user group being edited. If a new user group is
+     * being created, this will not be defined.
+     *
+     * @type String
+     */
+    var identifier = $routeParams.id;
+
+    /**
+     * The unique identifier of the data source containing the user group being
+     * edited.
+     *
+     * @type String
+     */
+    $scope.dataSource = $routeParams.dataSource;
+
+    /**
+     * All user groups associated with the same identifier as the group being
+     * created or edited, as a map of data source identifier to the UserGroup
+     * object within that data source.
+     *
+     * @type Object.<String, UserGroup>
+     */
+    $scope.userGroups = null;
+
+    /**
+     * The user group being modified.
+     *
+     * @type UserGroup
+     */
+    $scope.userGroup = null;
+
+    /**
+     * All permissions associated with the user group being modified.
+     * 
+     * @type PermissionFlagSet
+     */
+    $scope.permissionFlags = null;
+
+    /**
+     * The set of permissions that will be added to the user group when the
+     * user group is saved. Permissions will only be present in this set if they
+     * are manually added, and not later manually removed before saving.
+     *
+     * @type PermissionSet
+     */
+    $scope.permissionsAdded = new PermissionSet();
+
+    /**
+     * The set of permissions that will be removed from the user group when the
+     * user group is saved. Permissions will only be present in this set if they
+     * are manually removed, and not later manually added before saving.
+     *
+     * @type PermissionSet
+     */
+    $scope.permissionsRemoved = new PermissionSet();
+
+    /**
+     * The identifiers of all user groups which can be manipulated (all groups
+     * for which the user accessing this interface has UPDATE permission),
+     * whether that means changing the members of those groups or changing the
+     * groups of which those groups are members. If this information has not
+     * yet been retrieved, this will be null.
+     *
+     * @type String[]
+     */
+    $scope.availableGroups = null;
+
+    /**
+     * The identifiers of all users which can be manipulated (all users for
+     * which the user accessing this interface has UPDATE permission), either
+     * through adding those users as a member of the current group or removing
+     * those users from the current group. If this information has not yet been
+     * retrieved, this will be null.
+     *
+     * @type String[]
+     */
+    $scope.availableUsers = null;
+
+    /**
+     * The identifiers of all user groups of which this group is a member,
+     * taking into account any user groups which will be added/removed when
+     * saved. If this information has not yet been retrieved, this will be
+     * null.
+     *
+     * @type String[]
+     */
+    $scope.parentGroups = null;
+
+    /**
+     * The set of identifiers of all parent user groups to which this group
+     * will be added when saved. Parent groups will only be present in this set
+     * if they are manually added, and not later manually removed before
+     * saving.
+     *
+     * @type String[]
+     */
+    $scope.parentGroupsAdded = [];
+
+    /**
+     * The set of identifiers of all parent user groups from which this group
+     * will be removed when saved. Parent groups will only be present in this
+     * set if they are manually removed, and not later manually added before
+     * saving.
+     *
+     * @type String[]
+     */
+    $scope.parentGroupsRemoved = [];
+
+    /**
+     * The identifiers of all user groups which are members of this group,
+     * taking into account any user groups which will be added/removed when
+     * saved. If this information has not yet been retrieved, this will be
+     * null.
+     *
+     * @type String[]
+     */
+    $scope.memberGroups = null;
+
+    /**
+     * The set of identifiers of all member user groups which will be added to
+     * this group when saved. Member groups will only be present in this set if
+     * they are manually added, and not later manually removed before saving.
+     *
+     * @type String[]
+     */
+    $scope.memberGroupsAdded = [];
+
+    /**
+     * The set of identifiers of all member user groups which will be removed
+     * from this group when saved. Member groups will only be present in this
+     * set if they are manually removed, and not later manually added before
+     * saving.
+     *
+     * @type String[]
+     */
+    $scope.memberGroupsRemoved = [];
+
+    /**
+     * The identifiers of all users which are members of this group, taking
+     * into account any users which will be added/removed when saved. If this
+     * information has not yet been retrieved, this will be null.
+     *
+     * @type String[]
+     */
+    $scope.memberUsers = null;
+
+    /**
+     * The set of identifiers of all member users which will be added to this
+     * group when saved. Member users will only be present in this set if they
+     * are manually added, and not later manually removed before saving.
+     *
+     * @type String[]
+     */
+    $scope.memberUsersAdded = [];
+
+    /**
+     * The set of identifiers of all member users which will be removed from
+     * this group when saved. Member users will only be present in this set if
+     * they are manually removed, and not later manually added before saving.
+     *
+     * @type String[]
+     */
+    $scope.memberUsersRemoved = [];
+
+    /**
+     * For each applicable data source, the management-related actions that the
+     * current user may perform on the user group currently being created
+     * or modified, as a map of data source identifier to the
+     * {@link ManagementPermissions} object describing the actions available
+     * within that data source, or null if the current user's permissions have
+     * not yet been loaded.
+     *
+     * @type Object.<String, ManagementPermissions>
+     */
+    $scope.managementPermissions = null;
+
+    /**
+     * All available user group attributes. This is only the set of attribute
+     * definitions, organized as logical groupings of attributes, not attribute
+     * values.
+     *
+     * @type Form[]
+     */
+    $scope.attributes = null;
+
+    /**
+     * Returns whether critical data has completed being loaded.
+     *
+     * @returns {Boolean}
+     *     true if enough data has been loaded for the user group interface to
+     *     be useful, false otherwise.
+     */
+    $scope.isLoaded = function isLoaded() {
+
+        return $scope.userGroups            !== null
+            && $scope.permissionFlags       !== null
+            && $scope.managementPermissions !== null
+            && $scope.availableGroups       !== null
+            && $scope.availableUsers        !== null
+            && $scope.parentGroups          !== null
+            && $scope.memberGroups          !== null
+            && $scope.memberUsers           !== null
+            && $scope.attributes            !== null;
+
+    };
+
+    /**
+     * Returns whether the current user can edit the identifier of the user
+     * group being edited.
+     *
+     * @returns {Boolean}
+     *     true if the current user can edit the identifier of the user group
+     *     being edited, false otherwise.
+     */
+    $scope.canEditIdentifier = function canEditIdentifier() {
+        return !identifier;
+    };
+
+    /**
+     * Loads the data associated with the user group having the given
+     * identifier, preparing the interface for making modifications to that
+     * existing user group.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group
+     *     to load.
+     *
+     * @param {String} identifier
+     *     The unique identifier of the user group to load.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared for
+     *     editing the given user group.
+     */
+    var loadExistingUserGroup = function loadExistingGroup(dataSource, identifier) {
+        return $q.all({
+            userGroups   : dataSourceService.apply(userGroupService.getUserGroup, dataSources, identifier),
+            permissions  : permissionService.getPermissions(dataSource, identifier, true),
+            parentGroups : membershipService.getUserGroups(dataSource, identifier, true),
+            memberGroups : membershipService.getMemberUserGroups(dataSource, identifier),
+            memberUsers  : membershipService.getMemberUsers(dataSource, identifier)
+        })
+        .then(function userGroupDataRetrieved(values) {
+
+            $scope.userGroups = values.userGroups;
+            $scope.userGroup  = values.userGroups[dataSource];
+            $scope.parentGroups = values.parentGroups;
+            $scope.memberGroups = values.memberGroups;
+            $scope.memberUsers = values.memberUsers;
+
+            // Create skeleton user group if user group does not exist
+            if (!$scope.userGroup)
+                $scope.userGroup = new UserGroup({
+                    'identifier' : identifier
+                });
+
+            $scope.permissionFlags = PermissionFlagSet.fromPermissionSet(values.permissions);
+
+        });
+    };
+
+    /**
+     * Loads the data associated with the user group having the given
+     * identifier, preparing the interface for cloning that existing user
+     * group.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group to
+     *     be cloned.
+     *
+     * @param {String} identifier
+     *     The unique identifier of the user group being cloned.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared for
+     *     cloning the given user group.
+     */
+    var loadClonedUserGroup = function loadClonedUserGroup(dataSource, identifier) {
+        return $q.all({
+            userGroups   : dataSourceService.apply(userGroupService.getUserGroup, [dataSource], identifier),
+            permissions  : permissionService.getPermissions(dataSource, identifier, true),
+            parentGroups : membershipService.getUserGroups(dataSource, identifier, true),
+            memberGroups : membershipService.getMemberUserGroups(dataSource, identifier),
+            memberUsers  : membershipService.getMemberUsers(dataSource, identifier)
+        })
+        .then(function userGroupDataRetrieved(values) {
+
+            $scope.userGroups = {};
+            $scope.userGroup  = values.userGroups[dataSource];
+            $scope.parentGroups = values.parentGroups;
+            $scope.parentGroupsAdded = values.parentGroups;
+            $scope.memberGroups = values.memberGroups;
+            $scope.memberGroupsAdded = values.memberGroups;
+            $scope.memberUsers = values.memberUsers;
+            $scope.memberUsersAdded = values.memberUsers;
+
+            $scope.permissionFlags = PermissionFlagSet.fromPermissionSet(values.permissions);
+            $scope.permissionsAdded = values.permissions;
+
+        });
+    };
+
+    /**
+     * Loads skeleton user group data, preparing the interface for creating a
+     * new user group.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared for
+     *     creating a new user group.
+     */
+    var loadSkeletonUserGroup = function loadSkeletonUserGroup() {
+
+        // No user groups exist regardless of data source if the user group is
+        // being created
+        $scope.userGroups = {};
+
+        // Use skeleton user group object with no associated permissions
+        $scope.userGroup = new UserGroup();
+        $scope.parentGroups = [];
+        $scope.memberGroups = [];
+        $scope.memberUsers = [];
+        $scope.permissionFlags = new PermissionFlagSet();
+
+        return $q.resolve();
+
+    };
+
+    /**
+     * Loads the data required for performing the management task requested
+     * through the route parameters given at load time, automatically preparing
+     * the interface for editing an existing user group, cloning an existing
+     * user group, or creating an entirely new user group.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared
+     *     for performing the requested management task.
+     */
+    var loadRequestedUserGroup = function loadRequestedUserGroup() {
+
+        // Pull user group data and permissions if we are editing an existing
+        // user group
+        if (identifier)
+            return loadExistingUserGroup($scope.dataSource, identifier);
+
+        // If we are cloning an existing user group, pull its data instead
+        if (cloneSourceIdentifier)
+            return loadClonedUserGroup($scope.dataSource, cloneSourceIdentifier);
+
+        // If we are creating a new user group, populate skeleton user group data
+        return loadSkeletonUserGroup();
+
+    };
+
+    // Populate interface with requested data
+    $q.all({
+        userGroupData : loadRequestedUserGroup(),
+        permissions   : dataSourceService.apply(permissionService.getEffectivePermissions, dataSources, currentUsername),
+        userGroups    : userGroupService.getUserGroups($scope.dataSource, [ PermissionSet.ObjectPermissionType.UPDATE ]),
+        users         : userService.getUsers($scope.dataSource, [ PermissionSet.ObjectPermissionType.UPDATE ]),
+        attributes    : schemaService.getUserGroupAttributes($scope.dataSource)
+    })
+    .then(function dataReceived(values) {
+
+        $scope.attributes = values.attributes;
+
+        $scope.managementPermissions = {};
+        angular.forEach(dataSources, function deriveManagementPermissions(dataSource) {
+
+            // Determine whether data source contains this user group
+            var exists = (dataSource in $scope.userGroups);
+
+            // Add the identifiers of all modifiable user groups
+            $scope.availableGroups = [];
+            angular.forEach(values.userGroups, function addUserGroupIdentifier(userGroup) {
+                $scope.availableGroups.push(userGroup.identifier);
+            });
+
+            // Add the identifiers of all modifiable users
+            $scope.availableUsers = [];
+            angular.forEach(values.users, function addUserIdentifier(user) {
+                $scope.availableUsers.push(user.username);
+            });
+
+            // Calculate management actions available for this specific group
+            $scope.managementPermissions[dataSource] = ManagementPermissions.fromPermissionSet(
+                    values.permissions[dataSource],
+                    PermissionSet.SystemPermissionType.CREATE_USER_GROUP,
+                    PermissionSet.hasUserGroupPermission,
+                    exists ? identifier : null);
+
+        });
+
+    }, requestService.WARN);
+
+    /**
+     * Returns the URL for the page which manages the user group currently
+     * being edited under the given data source. The given data source need not
+     * be the same as the data source currently selected.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source that the URL is being
+     *     generated for.
+     *
+     * @returns {String}
+     *     The URL for the page which manages the user group currently being
+     *     edited under the given data source.
+     */
+    $scope.getUserGroupURL = function getUserGroupURL(dataSource) {
+        return '/manage/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(identifier || '');
+    };
+
+    /**
+     * Cancels all pending edits, returning to the main list of user groups.
+     */
+    $scope.returnToUserGroupList = function returnToUserGroupList() {
+        $location.url('/settings/userGroups');
+    };
+
+    /**
+     * Cancels all pending edits, opening an edit page for a new user group
+     * which is prepopulated with the data from the user currently being edited.
+     */
+    $scope.cloneUserGroup = function cloneUserGroup() {
+        $location.path('/manage/' + encodeURIComponent($scope.dataSource) + '/userGroups').search('clone', identifier);
+    };
+
+    /**
+     * Saves the current user group, creating a new user group or updating the
+     * existing user group depending on context, returning a promise which is
+     * resolved if the save operation succeeds and rejected if the save
+     * operation fails.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved if the save operation succeeds and is
+     *     rejected with an {@link Error} if the save operation fails.
+     */
+    $scope.saveUserGroup = function saveUserGroup() {
+
+        // Save or create the user group, depending on whether the user group exists
+        var saveUserGroupPromise;
+        if ($scope.dataSource in $scope.userGroups)
+            saveUserGroupPromise = userGroupService.saveUserGroup($scope.dataSource, $scope.userGroup);
+        else
+            saveUserGroupPromise = userGroupService.createUserGroup($scope.dataSource, $scope.userGroup);
+
+        return saveUserGroupPromise.then(function savedUserGroup() {
+            return $q.all([
+                permissionService.patchPermissions($scope.dataSource, $scope.userGroup.identifier, $scope.permissionsAdded, $scope.permissionsRemoved, true),
+                membershipService.patchUserGroups($scope.dataSource, $scope.userGroup.identifier, $scope.parentGroupsAdded, $scope.parentGroupsRemoved, true),
+                membershipService.patchMemberUserGroups($scope.dataSource, $scope.userGroup.identifier, $scope.memberGroupsAdded, $scope.memberGroupsRemoved),
+                membershipService.patchMemberUsers($scope.dataSource, $scope.userGroup.identifier, $scope.memberUsersAdded, $scope.memberUsersRemoved)
+            ]);
+        });
+
+    };
+
+    /**
+     * Deletes the current user group, returning a promise which is resolved if
+     * the delete operation succeeds and rejected if the delete operation
+     * fails.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved if the delete operation succeeds and is
+     *     rejected with an {@link Error} if the delete operation fails.
+     */
+    $scope.deleteUserGroup = function deleteUserGroup() {
+        return userGroupService.deleteUserGroup($scope.dataSource, $scope.userGroup);
+    };
+
+}]);

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js b/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js
index ec41872..67fd3f4 100644
--- a/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js
+++ b/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js
@@ -126,6 +126,10 @@ angular.module('manage').directive('systemPermissionEditor', ['$injector',
                 value: PermissionSet.SystemPermissionType.CREATE_USER
             },
             {
+                label: "MANAGE_USER.FIELD_HEADER_CREATE_NEW_USER_GROUPS",
+                value: PermissionSet.SystemPermissionType.CREATE_USER_GROUP
+            },
+            {
                 label: "MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTIONS",
                 value: PermissionSet.SystemPermissionType.CREATE_CONNECTION
             },

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/manage/styles/manage-user-group.css
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/manage/styles/manage-user-group.css b/guacamole/src/main/webapp/app/manage/styles/manage-user-group.css
new file mode 100644
index 0000000..df9e80d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/manage-user-group.css
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.manage-user-group .page-tabs .page-list li.read-only a[href],
+.manage-user-group .page-tabs .page-list li.unlinked  a[href],
+.manage-user-group .page-tabs .page-list li.linked    a[href] {
+    padding-right: 2.5em;
+    position: relative;
+}
+
+.manage-user-group .page-tabs .page-list li.read-only a[href]:before,
+.manage-user-group .page-tabs .page-list li.unlinked  a[href]:before,
+.manage-user-group .page-tabs .page-list li.linked    a[href]:before {
+    content: ' ';
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    top: 0;
+    width: 2.5em;
+    background-size: 1.25em;
+    background-repeat: no-repeat;
+    background-position: center;
+}
+
+.manage-user-group .page-tabs .page-list li.read-only a[href]:before {
+    background-image: url('images/lock.png');
+}
+
+.manage-user-group .page-tabs .page-list li.unlinked a[href]:before {
+    background-image: url('images/plus.png');
+}
+
+.manage-user-group .page-tabs .page-list li.unlinked a[href] {
+    opacity: 0.5;
+}
+
+.manage-user-group .page-tabs .page-list li.unlinked a[href]:hover,
+.manage-user-group .page-tabs .page-list li.unlinked a[href].current {
+    opacity: 1;
+}
+
+.manage-user-group .page-tabs .page-list li.linked a[href]:before {
+    background-image: url('images/checkmark.png');
+}
+
+.manage-user-group .notice.read-only {
+
+    background: #FDA;
+    border: 1px solid rgba(0, 0, 0, 0.125);
+    border-radius: 0.25em;
+
+    text-align: center;
+    padding: 1em;
+
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/manage/templates/manageUserGroup.html
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/manage/templates/manageUserGroup.html b/guacamole/src/main/webapp/app/manage/templates/manageUserGroup.html
new file mode 100644
index 0000000..c659915
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/manageUserGroup.html
@@ -0,0 +1,101 @@
+<div class="manage-user-group view" ng-class="{loading: !isLoaded()}">
+
+    <!-- User group header and data source tabs -->
+    <div class="header tabbed">
+        <h2>{{'MANAGE_USER_GROUP.SECTION_HEADER_EDIT_USER_GROUP' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+    <data-data-source-tabs ng-hide="cloneSourceIdentifier"
+        permissions="managementPermissions"
+        url="getUserGroupURL(dataSource)">
+    </data-data-source-tabs>
+
+    <!-- Warn if user group is read-only -->
+    <div class="section" ng-hide="managementPermissions[dataSource].canSaveObject">
+        <p class="notice read-only">{{'MANAGE_USER_GROUP.INFO_READ_ONLY' | translate}}</p>
+    </div>
+
+    <!-- Sections applicable to non-read-only user groups -->
+    <div ng-show="managementPermissions[dataSource].canSaveObject">
+
+        <!-- User group name -->
+        <div class="section">
+            <table class="properties">
+                <tr>
+                    <th>{{'MANAGE_USER_GROUP.FIELD_HEADER_USER_GROUP_NAME' | translate}}</th>
+                    <td>
+                        <input ng-show="canEditIdentifier()" ng-model="userGroup.identifier" type="text"/>
+                        <span  ng-hide="canEditIdentifier()">{{userGroup.identifier}}</span>
+                    </td>
+                </tr>
+            </table>
+        </div>
+
+        <!-- User group attributes section -->
+        <div class="attributes" ng-show="managementPermissions[dataSource].canChangeAttributes">
+            <guac-form namespace="'USER_GROUP_ATTRIBUTES'" content="attributes"
+                       model="userGroup.attributes"
+                       model-only="!managementPermissions[dataSource].canChangeAllAttributes"></guac-form>
+        </div>
+
+        <!-- System permissions section -->
+        <system-permission-editor ng-show="managementPermissions[dataSource].canChangePermissions"
+              data-data-source="dataSource"
+              permission-flags="permissionFlags"
+              permissions-added="permissionsAdded"
+              permissions-removed="permissionsRemoved">
+        </system-permission-editor>
+
+        <!-- Parent group section -->
+        <identifier-set-editor
+            header="MANAGE_USER_GROUP.SECTION_HEADER_USER_GROUPS"
+            empty-placeholder="MANAGE_USER_GROUP.HELP_NO_USER_GROUPS"
+            unavailable-placeholder="MANAGE_USER_GROUP.INFO_NO_USER_GROUPS_AVAILABLE"
+            identifiers-available="availableGroups"
+            identifiers="parentGroups"
+            identifiers-added="parentGroupsAdded"
+            identifiers-removed="parentGroupsRemoved">
+        </identifier-set-editor>
+
+        <!-- Member group section -->
+        <identifier-set-editor
+            header="MANAGE_USER_GROUP.SECTION_HEADER_MEMBER_USER_GROUPS"
+            empty-placeholder="MANAGE_USER_GROUP.HELP_NO_MEMBER_USER_GROUPS"
+            unavailable-placeholder="MANAGE_USER_GROUP.INFO_NO_USER_GROUPS_AVAILABLE"
+            identifiers-available="availableGroups"
+            identifiers="memberGroups"
+            identifiers-added="memberGroupsAdded"
+            identifiers-removed="memberGroupsRemoved">
+        </identifier-set-editor>
+
+        <!-- Member user section -->
+        <identifier-set-editor
+            header="MANAGE_USER_GROUP.SECTION_HEADER_MEMBER_USERS"
+            empty-placeholder="MANAGE_USER_GROUP.HELP_NO_MEMBER_USERS"
+            unavailable-placeholder="MANAGE_USER_GROUP.INFO_NO_USERS_AVAILABLE"
+            identifiers-available="availableUsers"
+            identifiers="memberUsers"
+            identifiers-added="memberUsersAdded"
+            identifiers-removed="memberUsersRemoved">
+        </identifier-set-editor>
+
+        <!-- Connection permissions section -->
+        <connection-permission-editor ng-show="managementPermissions[dataSource].canChangePermissions"
+              data-data-source="dataSource"
+              permission-flags="permissionFlags"
+              permissions-added="permissionsAdded"
+              permissions-removed="permissionsRemoved">
+        </connection-permission-editor>
+
+        <!-- Form action buttons -->
+        <management-buttons namespace="MANAGE_USER_GROUP"
+              permissions="managementPermissions[dataSource]"
+              save="saveUserGroup()"
+              delete="deleteUserGroup()"
+              clone="cloneUserGroup()"
+              return="returnToUserGroupList()">
+        </management-buttons>
+
+    </div>
+
+</div>

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/manage/types/ManageableUserGroup.js
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/manage/types/ManageableUserGroup.js b/guacamole/src/main/webapp/app/manage/types/ManageableUserGroup.js
new file mode 100644
index 0000000..6853fa0
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/types/ManageableUserGroup.js
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * A service for defining the ManageableUserGroup class.
+ */
+angular.module('manage').factory('ManageableUserGroup', [function defineManageableUserGroup() {
+
+    /**
+     * A pairing of an @link{UserGroup} with the identifier of its corresponding
+     * data source.
+     *
+     * @constructor
+     * @param {Object|ManageableUserGroup} template
+     */
+    var ManageableUserGroup = function ManageableUserGroup(template) {
+
+        /**
+         * The unique identifier of the data source containing this user.
+         *
+         * @type String
+         */
+        this.dataSource = template.dataSource;
+
+        /**
+         * The @link{UserGroup} object represented by this ManageableUserGroup
+         * and contained within the associated data source.
+         *
+         * @type UserGroup
+         */
+        this.userGroup = template.userGroup;
+
+    };
+
+    return ManageableUserGroup;
+
+}]);

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/navigation/services/userPageService.js
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/navigation/services/userPageService.js b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
index 4d1e612..f5bc308 100644
--- a/guacamole/src/main/webapp/app/navigation/services/userPageService.js
+++ b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
@@ -192,6 +192,7 @@ angular.module('navigation').factory('userPageService', ['$injector',
         var pages = [];
         
         var canManageUsers = [];
+        var canManageUserGroups = [];
         var canManageConnections = [];
         var canViewConnectionRecords = [];
         var canManageSessions = [];
@@ -235,6 +236,24 @@ angular.module('navigation').factory('userPageService', ['$injector',
                 canManageUsers.push(dataSource);
             }
 
+            // Determine whether the current user needs access to the group management UI
+            if (
+                    // System permissions
+                       PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+                    || PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.CREATE_USER_GROUP)
+
+                    // Permission to update user groups
+                    || PermissionSet.hasUserGroupPermission(permissions, PermissionSet.ObjectPermissionType.UPDATE)
+
+                    // Permission to delete user groups
+                    || PermissionSet.hasUserGroupPermission(permissions, PermissionSet.ObjectPermissionType.DELETE)
+
+                    // Permission to administer user groups
+                    || PermissionSet.hasUserGroupPermission(permissions, PermissionSet.ObjectPermissionType.ADMINISTER)
+            ) {
+                canManageUserGroups.push(dataSource);
+            }
+
             // Determine whether the current user needs access to the connection management UI
             if (
                     // System permissions
@@ -295,6 +314,14 @@ angular.module('navigation').factory('userPageService', ['$injector',
             }));
         }
 
+        // If user can manage user groups, add link to group management page
+        if (canManageUserGroups.length) {
+            pages.push(new PageDefinition({
+                name : 'USER_MENU.ACTION_MANAGE_USER_GROUPS',
+                url  : '/settings/userGroups'
+            }));
+        }
+
         // If user can manage connections, add links for connection management pages
         angular.forEach(canManageConnections, function addConnectionManagementLink(dataSource) {
             pages.push(new PageDefinition({

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/settings/controllers/settingsController.js b/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
index 91ef633..a462d87 100644
--- a/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
+++ b/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
@@ -36,8 +36,8 @@ angular.module('manage').controller('settingsController', ['$scope', '$injector'
     $scope.settingsPages = null;
 
     /**
-     * The currently-selected settings tab. This may be 'users', 'connections',
-     * or 'sessions'.
+     * The currently-selected settings tab. This may be 'users', 'userGroups',
+     * 'connections', 'history', 'preferences', or 'sessions'.
      *
      * @type String
      */

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/settings/directives/guacSettingsUserGroups.js
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsUserGroups.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUserGroups.js
new file mode 100644
index 0000000..5d45bc1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUserGroups.js
@@ -0,0 +1,270 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * A directive for managing all user groups in the system.
+ */
+angular.module('settings').directive('guacSettingsUserGroups', ['$injector',
+    function guacSettingsUserGroups($injector) {
+
+    // Required types
+    var ManageableUserGroup = $injector.get('ManageableUserGroup');
+    var PermissionSet       = $injector.get('PermissionSet');
+    var SortOrder           = $injector.get('SortOrder');
+
+    // Required services
+    var $location              = $injector.get('$location');
+    var authenticationService  = $injector.get('authenticationService');
+    var dataSourceService      = $injector.get('dataSourceService');
+    var permissionService      = $injector.get('permissionService');
+    var requestService         = $injector.get('requestService');
+    var userGroupService       = $injector.get('userGroupService');
+
+    var directive = {
+        restrict    : 'E',
+        replace     : true,
+        templateUrl : 'app/settings/templates/settingsUserGroups.html',
+        scope       : {}
+    };
+
+    directive.controller = ['$scope', function settingsUserGroupsController($scope) {
+
+        // Identifier of the current user
+        var currentUsername = authenticationService.getCurrentUsername();
+
+        /**
+         * The identifiers of all data sources accessible by the current
+         * user.
+         *
+         * @type String[]
+         */
+        var dataSources = authenticationService.getAvailableDataSources();
+
+        /**
+         * Map of data source identifiers to all permissions associated
+         * with the current user within that data source, or null if the
+         * user's permissions have not yet been loaded.
+         *
+         * @type Object.<String, PermissionSet>
+         */
+        var permissions = null;
+
+        /**
+         * All visible user groups, along with their corresponding data
+         * sources.
+         *
+         * @type ManageableUserGroup[]
+         */
+        $scope.manageableUserGroups = null;
+
+        /**
+         * Array of all user group properties that are filterable.
+         *
+         * @type String[]
+         */
+        $scope.filteredUserGroupProperties = [
+            'userGroup.identifier'
+        ];
+
+        /**
+         * SortOrder instance which stores the sort order of the listed
+         * user groups.
+         *
+         * @type SortOrder
+         */
+        $scope.order = new SortOrder([
+            'userGroup.identifier'
+        ]);
+
+        /**
+         * Returns whether critical data has completed being loaded.
+         *
+         * @returns {Boolean}
+         *     true if enough data has been loaded for the user group
+         *     interface to be useful, false otherwise.
+         */
+        $scope.isLoaded = function isLoaded() {
+            return $scope.manageableUserGroups !== null;
+        };
+
+        /**
+         * Returns the identifier of the data source that should be used by
+         * default when creating a new user group.
+         *
+         * @return {String}
+         *     The identifier of the data source that should be used by
+         *     default when creating a new user group, or null if user group
+         *     creation is not allowed.
+         */
+        $scope.getDefaultDataSource = function getDefaultDataSource() {
+
+            // Abort if permissions have not yet loaded
+            if (!permissions)
+                return null;
+
+            // For each data source
+            for (var dataSource in permissions) {
+
+                // Retrieve corresponding permission set
+                var permissionSet = permissions[dataSource];
+
+                // Can create user groups if adminstrator or have explicit permission
+                if (PermissionSet.hasSystemPermission(permissionSet, PermissionSet.SystemPermissionType.ADMINISTER)
+                 || PermissionSet.hasSystemPermission(permissionSet, PermissionSet.SystemPermissionType.CREATE_USER_GROUP))
+                    return dataSource;
+
+            }
+
+            // No data sources allow user group creation
+            return null;
+
+        };
+
+        /**
+         * Returns whether the current user can create new user groups
+         * within at least one data source.
+         *
+         * @return {Boolean}
+         *     true if the current user can create new user groups within at
+         *     least one data source, false otherwise.
+         */
+        $scope.canCreateUserGroups = function canCreateUserGroups() {
+            return $scope.getDefaultDataSource() !== null;
+        };
+
+        /**
+         * Returns whether the current user can create new user groups or
+         * make changes to existing user groups within at least one data
+         * source. The user group management interface as a whole is useless
+         * if this function returns false.
+         *
+         * @return {Boolean}
+         *     true if the current user can create new user groups or make
+         *     changes to existing user groups within at least one data
+         *     source, false otherwise.
+         */
+        var canManageUserGroups = function canManageUserGroups() {
+
+            // Abort if permissions have not yet loaded
+            if (!permissions)
+                return false;
+
+            // Creating user groups counts as management
+            if ($scope.canCreateUserGroups())
+                return true;
+
+            // For each data source
+            for (var dataSource in permissions) {
+
+                // Retrieve corresponding permission set
+                var permissionSet = permissions[dataSource];
+
+                // Can manage user groups if granted explicit update or delete
+                if (PermissionSet.hasUserGroupPermission(permissionSet, PermissionSet.ObjectPermissionType.UPDATE)
+                 || PermissionSet.hasUserGroupPermission(permissionSet, PermissionSet.ObjectPermissionType.DELETE))
+                    return true;
+
+            }
+
+            // No data sources allow management of user groups
+            return false;
+
+        };
+
+        /**
+         * Sets the displayed list of user groups. If any user groups are
+         * already shown within the interface, those user groups are replaced
+         * with the given user groups.
+         *
+         * @param {Object.<String, PermissionSet>} permissions
+         *     A map of data source identifiers to all permissions associated
+         *     with the current user within that data source.
+         *
+         * @param {Object.<String, Object.<String, UserGroup>>} userGroups
+         *     A map of all user groups which should be displayed, where each
+         *     key is the data source identifier from which the user groups
+         *     were retrieved and each value is a map of user group identifiers
+         *     to their corresponding @link{UserGroup} objects.
+         */
+        var setDisplayedUserGroups = function setDisplayedUserGroups(permissions, userGroups) {
+
+            var addedUserGroups = {};
+            $scope.manageableUserGroups = [];
+
+            // For each user group in each data source
+            angular.forEach(dataSources, function addUserGroupList(dataSource) {
+                angular.forEach(userGroups[dataSource], function addUserGroup(userGroup) {
+
+                    // Do not add the same user group twice
+                    if (addedUserGroups[userGroup.identifier])
+                        return;
+
+                    // Link to default creation data source if we cannot manage this user
+                    if (!PermissionSet.hasSystemPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.ADMINISTER)
+                     && !PermissionSet.hasUserGroupPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.UPDATE, userGroup.identifier)
+                     && !PermissionSet.hasUserGroupPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.DELETE, userGroup.identifier))
+                        dataSource = $scope.getDefaultDataSource();
+
+                    // Add user group to overall list
+                    addedUserGroups[userGroup.identifier] = userGroup;
+                    $scope.manageableUserGroups.push(new ManageableUserGroup ({
+                        'dataSource' : dataSource,
+                        'userGroup'  : userGroup
+                    }));
+
+                });
+            });
+
+        };
+
+        // Retrieve current permissions
+        dataSourceService.apply(
+            permissionService.getEffectivePermissions,
+            dataSources,
+            currentUsername
+        )
+        .then(function permissionsRetrieved(retrievedPermissions) {
+
+            // Store retrieved permissions
+            permissions = retrievedPermissions;
+
+            // Return to home if there's nothing to do here
+            if (!canManageUserGroups())
+                $location.path('/');
+
+            // If user groups can be created, list all readable user groups
+            if ($scope.canCreateUserGroups())
+                return dataSourceService.apply(userGroupService.getUserGroups, dataSources);
+
+            // Otherwise, list only updateable/deletable users
+            return dataSourceService.apply(userGroupService.getUserGroups, dataSources, [
+                PermissionSet.ObjectPermissionType.UPDATE,
+                PermissionSet.ObjectPermissionType.DELETE
+            ]);
+
+        })
+        .then(function userGroupsReceived(userGroups) {
+            setDisplayedUserGroups(permissions, userGroups);
+        }, requestService.WARN);
+
+    }];
+
+    return directive;
+    
+}]);

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/settings/styles/buttons.css
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/settings/styles/buttons.css b/guacamole/src/main/webapp/app/settings/styles/buttons.css
index 17401c3..e530510 100644
--- a/guacamole/src/main/webapp/app/settings/styles/buttons.css
+++ b/guacamole/src/main/webapp/app/settings/styles/buttons.css
@@ -18,6 +18,7 @@
  */
 
 a.button.add-user,
+a.button.add-user-group,
 a.button.add-connection,
 a.button.add-connection-group {
     font-size: 0.8em;
@@ -26,6 +27,7 @@ a.button.add-connection-group {
 }
 
 a.button.add-user::before,
+a.button.add-user-group::before,
 a.button.add-connection::before,
 a.button.add-connection-group::before {
 
@@ -46,6 +48,10 @@ a.button.add-user::before {
     background-image: url('images/action-icons/guac-user-add.png');
 }
 
+a.button.add-user-group::before {
+    background-image: url('images/action-icons/guac-user-group-add.png');
+}
+
 a.button.add-connection::before {
     background-image: url('images/action-icons/guac-monitor-add.png');
 }

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/settings/styles/user-group-list.css
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/settings/styles/user-group-list.css b/guacamole/src/main/webapp/app/settings/styles/user-group-list.css
new file mode 100644
index 0000000..2040eb4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/user-group-list.css
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.settings.user-groups table.user-group-list {
+    width: 100%;
+}
+
+.settings.user-groups table.user-group-list th.user-group-name,
+.settings.user-groups table.user-group-list td.user-group-name {
+    width: 100%;
+}
+
+.settings.user-groups table.user-group-list tr.user td.user-group-name a[href] {
+    display: block;
+    padding: .5em 1em;
+}
+
+.settings.user-groups table.user-group-list tr.user td.user-group-name {
+    padding: 0;
+}

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/settings/templates/settings.html
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/settings/templates/settings.html b/guacamole/src/main/webapp/app/settings/templates/settings.html
index b29d809..2bae3ae 100644
--- a/guacamole/src/main/webapp/app/settings/templates/settings.html
+++ b/guacamole/src/main/webapp/app/settings/templates/settings.html
@@ -13,6 +13,7 @@
 
     <!-- Selected tab -->
     <guac-settings-users                ng-if="activeTab === 'users'"></guac-settings-users>
+    <guac-settings-user-groups          ng-if="activeTab === 'userGroups'"></guac-settings-user-groups>
     <guac-settings-connections          ng-if="activeTab === 'connections'"></guac-settings-connections>
     <guac-settings-connection-history   ng-if="activeTab === 'history'"></guac-settings-connection-history>
     <guac-settings-sessions             ng-if="activeTab === 'sessions'"></guac-settings-sessions>

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/app/settings/templates/settingsUserGroups.html
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsUserGroups.html b/guacamole/src/main/webapp/app/settings/templates/settingsUserGroups.html
new file mode 100644
index 0000000..1943773
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsUserGroups.html
@@ -0,0 +1,48 @@
+<div class="settings section user-groups" ng-class="{loading: !isLoaded()}">
+
+    <!-- User group management -->
+    <p>{{'SETTINGS_USER_GROUPS.HELP_USER_GROUPS' | translate}}</p>
+
+
+    <!-- User management toolbar -->
+    <div class="toolbar">
+
+        <!-- Form action buttons -->
+        <div class="action-buttons">
+            <a class="add-user-group button" ng-show="canCreateUserGroups()"
+               href="#/manage/{{getDefaultDataSource()}}/userGroups/">{{'SETTINGS_USER_GROUPS.ACTION_NEW_USER_GROUP' | translate}}</a>
+        </div>
+
+        <!-- User group filter -->
+        <guac-filter filtered-items="filteredManageableUserGroups" items="manageableUserGroups"
+                     placeholder="'SETTINGS_USER_GROUPS.FIELD_PLACEHOLDER_FILTER' | translate"
+                     properties="filteredUserGroupProperties"></guac-filter>
+
+    </div>
+
+    <!-- List of user groups this user has access to -->
+    <table class="sorted user-group-list">
+        <thead>
+            <tr>
+                <th guac-sort-order="order" guac-sort-property="'userGroup.identifier'" class="user-group-name">
+                    {{'SETTINGS_USER_GROUPS.TABLE_HEADER_USER_GROUP_NAME' | translate}}
+                </th>
+            </tr>
+        </thead>
+        <tbody ng-class="{loading: !isLoaded()}">
+            <tr ng-repeat="manageableUserGroup in manageableUserGroupPage" class="user-group">
+                <td class="user-group-name">
+                    <a ng-href="#/manage/{{manageableUserGroup.dataSource}}/userGroups/{{manageableUserGroup.userGroup.identifier}}">
+                        <div class="icon user-group"></div>
+                        <span class="name">{{manageableUserGroup.userGroup.identifier}}</span>
+                    </a>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+
+    <!-- Pager controls for user group list -->
+    <guac-pager page="manageableUserGroupPage" page-size="25"
+                items="filteredManageableUserGroups | orderBy : order.predicate"></guac-pager>
+
+</div>
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/images/action-icons/guac-user-group-add.png
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-user-group-add.png b/guacamole/src/main/webapp/images/action-icons/guac-user-group-add.png
new file mode 100644
index 0000000..a833433
Binary files /dev/null and b/guacamole/src/main/webapp/images/action-icons/guac-user-group-add.png differ

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/images/user-icons/guac-user-group.png
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/images/user-icons/guac-user-group.png b/guacamole/src/main/webapp/images/user-icons/guac-user-group.png
new file mode 100644
index 0000000..4eb0aa4
Binary files /dev/null and b/guacamole/src/main/webapp/images/user-icons/guac-user-group.png differ

http://git-wip-us.apache.org/repos/asf/guacamole-client/blob/8ad3f253/guacamole/src/main/webapp/translations/en.json
----------------------------------------------------------------------
diff --git a/guacamole/src/main/webapp/translations/en.json b/guacamole/src/main/webapp/translations/en.json
index d0eaa9a..24ab0d7 100644
--- a/guacamole/src/main/webapp/translations/en.json
+++ b/guacamole/src/main/webapp/translations/en.json
@@ -21,6 +21,7 @@
         "ACTION_MANAGE_SETTINGS"    : "Settings",
         "ACTION_MANAGE_SESSIONS"    : "Active Sessions",
         "ACTION_MANAGE_USERS"       : "Users",
+        "ACTION_MANAGE_USER_GROUPS" : "Groups",
         "ACTION_NAVIGATE_BACK"      : "Back",
         "ACTION_NAVIGATE_HOME"      : "Home",
         "ACTION_SAVE"               : "Save",
@@ -292,6 +293,7 @@
         "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Administer system:",
         "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Change own password:",
         "FIELD_HEADER_CREATE_NEW_USERS"              : "Create new users:",
+        "FIELD_HEADER_CREATE_NEW_USER_GROUPS"        : "Create new user groups:",
         "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Create new connections:",
         "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Create new connection groups:",
         "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "Create new sharing profiles:",
@@ -316,6 +318,49 @@
         "TEXT_CONFIRM_DELETE" : "Users cannot be restored after they have been deleted. Are you sure you want to delete this user?"
 
     },
+
+    "MANAGE_USER_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"         : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Delete Group",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "@:MANAGE_USER.FIELD_HEADER_ADMINISTER_SYSTEM",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "@:MANAGE_USER.FIELD_HEADER_CHANGE_OWN_PASSWORD",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_USERS",
+        "FIELD_HEADER_CREATE_NEW_USER_GROUPS"        : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_USER_GROUPS",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTIONS",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS",
+        "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_SHARING_PROFILES",
+        "FIELD_HEADER_USER_GROUP_NAME"               : "Group name:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_NO_USER_GROUPS"        : "This group does not currently belong to any groups. Expand this section to add groups.",
+        "HELP_NO_MEMBER_USER_GROUPS" : "This group does not currently contain any groups. Expand this section to add groups.",
+        "HELP_NO_MEMBER_USERS"       : "This group does not currently contain any users. Expand this section to add users.",
+
+        "INFO_READ_ONLY"                : "Sorry, but this group cannot be edited.",
+        "INFO_NO_USER_GROUPS_AVAILABLE" : "@:MANAGE_USER.INFO_NO_USER_GROUPS_AVAILABLE",
+        "INFO_NO_USERS_AVAILABLE"       : "No users available.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"     : "@:MANAGE_USER.SECTION_HEADER_ALL_CONNECTIONS",
+        "SECTION_HEADER_CONNECTIONS"         : "@:MANAGE_USER.SECTION_HEADER_CONNECTIONS",
+        "SECTION_HEADER_CURRENT_CONNECTIONS" : "@:MANAGE_USER.SECTION_HEADER_CURRENT_CONNECTIONS",
+        "SECTION_HEADER_EDIT_USER_GROUP"     : "Edit Group",
+        "SECTION_HEADER_MEMBER_USERS"        : "Member Users",
+        "SECTION_HEADER_MEMBER_USER_GROUPS"  : "Member Groups",
+        "SECTION_HEADER_PERMISSIONS"         : "@:MANAGE_USER.SECTION_HEADER_PERMISSIONS",
+        "SECTION_HEADER_USER_GROUPS"         : "Parent Groups",
+
+        "TEXT_CONFIRM_DELETE" : "Groups cannot be restored after they have been deleted. Are you sure you want to delete this group?"
+
+    },
     
     "PROTOCOL_RDP" : {
 
@@ -747,7 +792,26 @@
         "TABLE_HEADER_USERNAME"    : "Username"
 
     },
-    
+
+    "SETTINGS_USER_GROUPS" : {
+
+        "ACTION_ACKNOWLEDGE"    : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER_GROUP" : "New Group",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_USER_GROUPS" : "Click or tap on a group below to manage that group. Depending on your access level, groups can be added and deleted, and their member users and groups can be changed.",
+
+        "SECTION_HEADER_USER_GROUPS" : "Groups",
+
+        "TABLE_HEADER_USER_GROUP_NAME" : "Group Name"
+
+    },
+
     "SETTINGS_SESSIONS" : {
         
         "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
@@ -793,6 +857,7 @@
         "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
         "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
         "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_MANAGE_USER_GROUPS" : "@:APP.ACTION_MANAGE_USER_GROUPS",
         "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME",
         "ACTION_VIEW_HISTORY"       : "@:APP.ACTION_VIEW_HISTORY"