diff --git a/guacamole/src/main/webapp/app/groupList/directives/guacGroupListFilter.js b/guacamole/src/main/webapp/app/groupList/directives/guacGroupListFilter.js index 03523932d..5a3c6b554 100644 --- a/guacamole/src/main/webapp/app/groupList/directives/guacGroupListFilter.js +++ b/guacamole/src/main/webapp/app/groupList/directives/guacGroupListFilter.js @@ -30,9 +30,10 @@ angular.module('groupList').directive('guacGroupListFilter', [function guacGroup /** * The property to which a subset of the provided map of connection - * groups will be assigned. + * groups will be assigned. The type of each item within the + * original map is preserved within the filtered map. * - * @type Array + * @type Object. */ filteredConnectionGroups : '=', @@ -155,6 +156,94 @@ angular.module('groupList').directive('guacGroupListFilter', [function guacGroup }; + /** + * Flattens the connection group hierarchy of the given + * GroupListItem such that all descendants are copied as immediate + * children. The hierarchy of nested items is otherwise completely + * preserved. A connection or connection group nested two or more + * levels deep within the hierarchy will thus appear within the + * returned item in two places: in its original location AND as an + * immediate child. + * + * @param {GroupListItem} item + * The GroupListItem whose descendents should be copied as + * first-level children. + * + * @returns {GroupListItem} + * A new GroupListItem completely identical to the provided + * item, except that absolutely all descendents have been + * copied into the first level of children. + */ + var flattenGroupListItem = function flattenGroupListItem(item) { + + // Replace item with shallow copy + item = new GroupListItem(item); + + // Ensure children are defined and independent copies + item.children = angular.copy(item.children) || []; + + // Flatten all children to the top-level group + angular.forEach(item.children, function flattenChild(child) { + if (child.type === GroupListItem.Type.CONNECTION_GROUP) { + + var flattenedChild = flattenConnectionGroup(child); + + // Merge all children + Array.prototype.push.apply( + item.children, + flattenedChild.children + ); + + } + }); + + return item; + + }; + + /** + * Replaces the set of children within the given GroupListItem such + * that only children which match the filter predicate for the + * current search string are present. + * + * @param {GroupListItem} item + * The GroupListItem whose children should be filtered. + */ + var filterGroupListItem = function filterGroupListItem(item) { + item.children = item.children.filter(function applyFilterPattern(child) { + + // Filter connections and connection groups by + // given pattern + switch (child.type) { + + case GroupListItem.Type.CONNECTION: + return connectionFilterPattern.predicate(child.wrappedItem); + + case GroupListItem.Type.CONNECTION_GROUP: + return connectionGroupFilterPattern.predicate(child.wrappedItem); + + } + + // Include all other children + return true; + + }); + }; + + /** + * Replaces the set of child connections and connection groups + * within the given connection group such that only children which + * match the filter predicate for the current search string are + * present. + * + * @param {ConnectionGroup} connectionGroup + * The connection group whose children should be filtered. + */ + var filterConnectionGroup = function filterConnectionGroup(connectionGroup) { + connectionGroup.childConnections = connectionGroup.childConnections.filter(connectionFilterPattern.predicate); + connectionGroup.childConnectionGroups = connectionGroup.childConnectionGroups.filter(connectionGroupFilterPattern.predicate); + }; + /** * Applies the current filter predicate, filtering all provided * connection groups and storing the result in @@ -177,16 +266,17 @@ angular.module('groupList').directive('guacGroupListFilter', [function guacGroup if (connectionGroups) { angular.forEach(connectionGroups, function updateFilteredConnectionGroup(connectionGroup, dataSource) { - // Unwrap GroupListItem - if (connectionGroup instanceof GroupListItem) - connectionGroup = connectionGroup.wrappedItem; + var filteredGroup; - // Flatten hierarchy of connection group - var filteredGroup = flattenConnectionGroup(connectionGroup); - - // Filter all direct children - filteredGroup.childConnections = filteredGroup.childConnections.filter(connectionFilterPattern.predicate); - filteredGroup.childConnectionGroups = filteredGroup.childConnectionGroups.filter(connectionGroupFilterPattern.predicate); + // Flatten and filter depending on type + if (connectionGroup instanceof GroupListItem) { + filteredGroup = flattenGroupListItem(connectionGroup); + filterGroupListItem(filteredGroup); + } + else { + filteredGroup = flattenConnectionGroup(connectionGroup); + filterConnectionGroup(filteredGroup); + } // Store now-filtered root $scope.filteredConnectionGroups[dataSource] = filteredGroup; diff --git a/guacamole/src/main/webapp/app/index/styles/headers.css b/guacamole/src/main/webapp/app/index/styles/headers.css index 2e813f42f..b4d96f66b 100644 --- a/guacamole/src/main/webapp/app/index/styles/headers.css +++ b/guacamole/src/main/webapp/app/index/styles/headers.css @@ -74,6 +74,10 @@ h2 { } +.header.tabbed { + margin-bottom: 0; +} + .header ~ * .header, .header ~ .header { margin-top: 1em; diff --git a/guacamole/src/main/webapp/app/manage/directives/connectionPermissionEditor.js b/guacamole/src/main/webapp/app/manage/directives/connectionPermissionEditor.js index 43e80c2db..f47670a6e 100644 --- a/guacamole/src/main/webapp/app/manage/directives/connectionPermissionEditor.js +++ b/guacamole/src/main/webapp/app/manage/directives/connectionPermissionEditor.js @@ -84,6 +84,67 @@ angular.module('manage').directive('connectionPermissionEditor', ['$injector', directive.controller = ['$scope', function connectionPermissionEditorController($scope) { + /** + * A map of data source identifiers to all root connection groups + * within those data sources, regardless of the permissions granted for + * the items within those groups. As only one data source is applicable + * to any particular permission set being edited/created, this will only + * contain a single key. If the data necessary to produce this map has + * not yet been loaded, this will be null. + * + * @type Object. + */ + var allRootGroups = null; + + /** + * A map of data source identifiers to the root connection groups within + * those data sources, excluding all items which are not explicitly + * readable according to $scope.permissionFlags. As only one data + * source is applicable to any particular permission set being + * edited/created, this will only contain a single key. If the data + * necessary to produce this map has not yet been loaded, this will be + * null. + * + * @type Object. + */ + var readableRootGroups = null; + + /** + * The name of the tab within the connection permission editor which + * displays currently selected (readable) connections only. + * + * @constant + * @type String + */ + var CURRENT_CONNECTIONS = 'CURRENT_CONNECTIONS'; + + /** + * The name of the tab within the connection permission editor which + * displays all connections, regardless of whether they are readable. + * + * @constant + * @type String + */ + var ALL_CONNECTIONS = 'ALL_CONNECTIONS'; + + /** + * The names of all tabs which should be available within the + * connection permission editor, in display order. + * + * @type String[] + */ + $scope.tabs = [ + CURRENT_CONNECTIONS, + ALL_CONNECTIONS + ]; + + /** + * The name of the currently selected tab. + * + * @type String + */ + $scope.currentTab = ALL_CONNECTIONS; + /** * Array of all connection properties that are filterable. * @@ -104,31 +165,53 @@ angular.module('manage').directive('connectionPermissionEditor', ['$injector', ]; /** - * A map of data source identifiers to the root connection groups within - * thost data sources. As only one data source is applicable to any - * particular permission set being edited/created, this will only - * contain a single key. + * Returns the root groups which should be displayed within the + * connection permission editor. * - * @type Object. + * @returns {Object.} + * The root groups which should be displayed within the connection + * permission editor as a map of data source identifiers to the + * root connection groups within those data sources. */ - $scope.rootGroups = null; + $scope.getRootGroups = function getRootGroups() { + return $scope.currentTab === CURRENT_CONNECTIONS ? readableRootGroups : allRootGroups; + }; - // Retrieve all connections for which we have ADMINISTER permission - dataSourceService.apply( - connectionGroupService.getConnectionGroupTree, - [$scope.dataSource], - ConnectionGroup.ROOT_IDENTIFIER, - [PermissionSet.ObjectPermissionType.ADMINISTER] - ) - .then(function connectionGroupReceived(rootGroups) { + /** + * Returns whether the given PermissionFlagSet declares explicit READ + * permission for the connection, connection group, or sharing profile + * represented by the given GroupListItem. + * + * @param {GroupListItem} item + * The GroupListItem which should be checked against the + * PermissionFlagSet. + * + * @param {PemissionFlagSet} flags + * The set of permissions which should be used to determine whether + * explicit READ permission is granted for the given item. + * + * @returns {Boolean} + * true if explicit READ permission is granted for the given item + * according to the given permission set, false otherwise. + */ + var isReadable = function isReadable(item, flags) { - // Convert all received ConnectionGroup objects into GroupListItems - $scope.rootGroups = {}; - angular.forEach(rootGroups, function addGroupListItem(rootGroup, dataSource) { - $scope.rootGroups[dataSource] = GroupListItem.fromConnectionGroup(dataSource, rootGroup); - }); + switch (item.type) { - }, requestService.WARN); + case GroupListItem.Type.CONNECTION: + return flags.connectionPermissions.READ[item.identifier]; + + case GroupListItem.Type.CONNECTION_GROUP: + return flags.connectionGroupPermissions.READ[item.identifier]; + + case GroupListItem.Type.SHARING_PROFILE: + return flags.sharingProfilePermissions.READ[item.identifier]; + + } + + return false; + + }; /** * Expands all items within the tree descending from the given @@ -144,6 +227,9 @@ angular.module('manage').directive('connectionPermissionEditor', ['$injector', * @param {PemissionFlagSet} flags * The set of permissions which should be used to determine whether * the given item and its descendants are expanded. + * + * @returns {Boolean} + * true if the given item has been expanded, false otherwise. */ var expandReadable = function expandReadable(item, flags) { @@ -152,29 +238,10 @@ angular.module('manage').directive('connectionPermissionEditor', ['$injector', if (item.expandable && item.children) { angular.forEach(item.children, function expandReadableChild(child) { - // Determine whether the permission set contains READ - // permission for the current child object - var readable = false; - switch (child.type) { - - case GroupListItem.Type.CONNECTION: - readable = flags.connectionPermissions.READ[child.identifier]; - break; - - case GroupListItem.Type.CONNECTION_GROUP: - readable = flags.connectionGroupPermissions.READ[child.identifier]; - break; - - case GroupListItem.Type.SHARING_PROFILE: - readable = flags.sharingProfilePermissions.READ[child.identifier]; - break; - - } - // The parent should be expanded by default if the child is // expanded by default OR the permission set contains READ // permission on the child - item.expanded |= expandReadable(child, flags) || readable; + item.expanded |= expandReadable(child, flags) || isReadable(child, flags); }); } @@ -183,22 +250,105 @@ angular.module('manage').directive('connectionPermissionEditor', ['$injector', }; - // Update default expanded state whenever connection groups and - // associated permissions change - $scope.$watchGroup(['rootGroups', 'permissionFlags'], function updateDefaultExpandedStates() { + /** + * Creates a deep copy of all items within the tree descending from the + * given GroupListItem which have at least one descendant for which + * explicit READ permission is granted. Items which lack explicit READ + * permission and which have no descendants having explicit READ + * permission are omitted from the copy. + * + * @param {GroupListItem} item + * The GroupListItem which should be conditionally copied + * depending on whether READ permission is granted for any of its + * descendants. + * + * @param {PemissionFlagSet} flags + * The set of permissions which should be used to determine whether + * the given item or any of its descendants are copied. + * + * @returns {GroupListItem} + * A new GroupListItem containing a deep copy of the given item, + * omitting any items which lack explicit READ permission and whose + * descendants also lack explicit READ permission, or null if even + * the given item would not be copied. + */ + var copyReadable = function copyReadable(item, flags) { - if (!$scope.rootGroups || !$scope.permissionFlags) - return; + // Produce initial shallow copy of given item + item = new GroupListItem(item); - angular.forEach($scope.rootGroups, function updateExpandedStates(rootGroup) { + // Replace children array with an array containing only readable + // children (or children with at least one readable descendant), + // flagging the current item for copying if any such children exist + if (item.children) { - // Automatically expand all objects with any descendants for - // which the permission set contains READ permission - expandReadable(rootGroup, $scope.permissionFlags); + var children = []; + angular.forEach(item.children, function copyReadableChildren(child) { + + // Reduce child tree to only explicitly readable items and + // their parents + child = copyReadable(child, flags); + + // Include child only if they are explicitly readable, they + // have explicitly readable descendants, or their parent is + // readable (and thus all children are relevant) + if ((child.children && child.children.length) + || isReadable(item, flags) + || isReadable(child, flags)) + children.push(child); + + }); + + item.children = children; + + } + + return item; + + }; + + // Retrieve all connections for which we have ADMINISTER permission + dataSourceService.apply( + connectionGroupService.getConnectionGroupTree, + [$scope.dataSource], + ConnectionGroup.ROOT_IDENTIFIER, + [PermissionSet.ObjectPermissionType.ADMINISTER] + ) + .then(function connectionGroupReceived(rootGroups) { + + // Update default expanded state and the all / readable-only views + // when associated permissions change + $scope.$watchGroup(['permissionFlags'], function updateDefaultExpandedStates() { + + if (!$scope.permissionFlags) + return; + + allRootGroups = {}; + readableRootGroups = {}; + + angular.forEach(rootGroups, function addGroupListItem(rootGroup, dataSource) { + + // Convert all received ConnectionGroup objects into GroupListItems + var item = GroupListItem.fromConnectionGroup(dataSource, rootGroup); + allRootGroups[dataSource] = item; + + // Automatically expand all objects with any descendants for + // which the permission set contains READ permission + expandReadable(item, $scope.permissionFlags); + + // Create a duplicate view which contains only readable + // items + readableRootGroups[dataSource] = copyReadable(item, $scope.permissionFlags); + + }); + + // Display only readable connections by default if at least one + // readable connection exists + $scope.currentTab = !!readableRootGroups[$scope.dataSource].children.length ? CURRENT_CONNECTIONS : ALL_CONNECTIONS; }); - }); + }, requestService.WARN); /** * Updates the permissionsAdded and permissionsRemoved permission sets diff --git a/guacamole/src/main/webapp/app/manage/styles/manage-user.css b/guacamole/src/main/webapp/app/manage/styles/manage-user.css index cc0cd5ccb..d54c6b391 100644 --- a/guacamole/src/main/webapp/app/manage/styles/manage-user.css +++ b/guacamole/src/main/webapp/app/manage/styles/manage-user.css @@ -17,10 +17,6 @@ * under the License. */ -.manage-user .username.header { - margin-bottom: 0; -} - .manage-user .page-tabs .page-list li.read-only a[href], .manage-user .page-tabs .page-list li.unlinked a[href], .manage-user .page-tabs .page-list li.linked a[href] { diff --git a/guacamole/src/main/webapp/app/manage/templates/connectionPermissionEditor.html b/guacamole/src/main/webapp/app/manage/templates/connectionPermissionEditor.html index 61d380408..f010fb9ca 100644 --- a/guacamole/src/main/webapp/app/manage/templates/connectionPermissionEditor.html +++ b/guacamole/src/main/webapp/app/manage/templates/connectionPermissionEditor.html @@ -1,12 +1,13 @@
-
+

{{'MANAGE_USER.SECTION_HEADER_CONNECTIONS' | translate}}

-
+
-
+

{{'MANAGE_USER.SECTION_HEADER_EDIT_USER' | translate}}

diff --git a/guacamole/src/main/webapp/app/navigation/directives/guacSectionTabs.js b/guacamole/src/main/webapp/app/navigation/directives/guacSectionTabs.js new file mode 100644 index 000000000..97c87c2e6 --- /dev/null +++ b/guacamole/src/main/webapp/app/navigation/directives/guacSectionTabs.js @@ -0,0 +1,143 @@ +/* + * 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. + */ + +/** + * Directive which displays a set of tabs dividing a section of a page into + * logical subsections or views. The currently selected tab is communicated + * through assignment to the variable bound to the current + * attribute. No navigation occurs as a result of selecting a tab. + */ +angular.module('navigation').directive('guacSectionTabs', ['$injector', + function guacSectionTabs($injector) { + + // Required services + var translationStringService = $injector.get('translationStringService'); + + var directive = { + + restrict : 'E', + replace : true, + templateUrl : 'app/navigation/templates/guacSectionTabs.html', + + scope : { + + /** + * The translation namespace to use when producing translation + * strings for each tab. Tab translation strings will be of the + * form: + * + * NAMESPACE.SECTION_HEADER_NAME + * + * where NAMESPACE is the namespace provided to this + * attribute and NAME is one of the names within the + * array provided to the tabs attribute and + * transformed via translationStringService.canonicalize(). + */ + namespace : '@', + + /** + * The name of the currently selected tab. This name MUST be one of + * the names present in the array given via the tabs + * attribute. This directive will not automatically choose an + * initially selected tab, and a default value should be manually + * assigned to current to ensure a tab is initially + * selected. + * + * @type String + */ + current : '=', + + /** + * The unique names of all tabs which should be made available, in + * display order. These names will be assigned to the variable + * bound to the current attribute when the current + * tab changes. + * + * @type String[] + */ + tabs : '=' + + } + + }; + + directive.controller = ['$scope', function dataSourceTabsController($scope) { + + /** + * Produces the translation string for the section header representing + * the tab having the given name. The translation string will be of the + * form: + * + * NAMESPACE.SECTION_HEADER_NAME + * + * where NAMESPACE is the namespace provided to the + * directive and NAME is the given name transformed + * via translationStringService.canonicalize(). + * + * @param {String} name + * The name of the tab. + * + * @returns {String} + * The translation string which produces the translated header + * of the tab having the given name. + */ + $scope.getSectionHeader = function getSectionHeader(name) { + + // If no name, then no header + if (!name) + return ''; + + return translationStringService.canonicalize($scope.namespace || 'MISSING_NAMESPACE') + + '.SECTION_HEADER_' + translationStringService.canonicalize(name); + + }; + + /** + * Selects the tab having the given name. The name of the currently + * selected tab will be communicated outside the directive through + * $scope.current. + * + * @param {String} name + * The name of the tab to select. + */ + $scope.selectTab = function selectTab(name) { + $scope.current = name; + }; + + /** + * Returns whether the tab having the given name is currently + * selected. A tab is currently selected if its name is stored within + * $scope.current, as assigned externally or by selectTab(). + * + * @param {String} name + * The name of the tab to test. + * + * @returns {Boolean} + * true if the tab having the given name is currently selected, + * false otherwise. + */ + $scope.isSelected = function isSelected(name) { + return $scope.current === name; + }; + + }]; + + return directive; + +}]); diff --git a/guacamole/src/main/webapp/app/navigation/styles/page-tabs.css b/guacamole/src/main/webapp/app/navigation/styles/tabs.css similarity index 76% rename from guacamole/src/main/webapp/app/navigation/styles/page-tabs.css rename to guacamole/src/main/webapp/app/navigation/styles/tabs.css index 5e88cd0e1..3d1c8cdec 100644 --- a/guacamole/src/main/webapp/app/navigation/styles/page-tabs.css +++ b/guacamole/src/main/webapp/app/navigation/styles/tabs.css @@ -17,23 +17,27 @@ * under the License. */ -.page-tabs .page-list ul { +.page-tabs .page-list ul, +.section-tabs ul { margin: 0; padding: 0; background: rgba(0, 0, 0, 0.0125); border-bottom: 1px solid rgba(0, 0, 0, 0.05); } -.page-tabs .page-list ul + ul { +.page-tabs .page-list ul + ul, +.section-tabs ul + ul { font-size: 0.75em; } -.page-tabs .page-list li { +.page-tabs .page-list li, +.section-tabs li { display: inline-block; list-style: none; } -.page-tabs .page-list li a[href] { +.page-tabs .page-list li a[href], +.section-tabs li a { display: block; color: black; text-decoration: none; @@ -44,12 +48,16 @@ color: black; } -.page-tabs .page-list li a[href]:hover { +.page-tabs .page-list li a[href]:hover, +.section-tabs li a:hover { background-color: #CDA; + cursor: pointer; } .page-tabs .page-list li a[href].current, -.page-tabs .page-list li a[href].current:hover { +.page-tabs .page-list li a[href].current:hover, +.section-tabs li a.current, +.section-tabs li a.current:hover { background: rgba(0,0,0,0.3); cursor: default; } diff --git a/guacamole/src/main/webapp/app/navigation/templates/guacSectionTabs.html b/guacamole/src/main/webapp/app/navigation/templates/guacSectionTabs.html new file mode 100644 index 000000000..a02871534 --- /dev/null +++ b/guacamole/src/main/webapp/app/navigation/templates/guacSectionTabs.html @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/guacamole/src/main/webapp/app/settings/styles/settings.css b/guacamole/src/main/webapp/app/settings/styles/settings.css index ee59cae8e..390a79318 100644 --- a/guacamole/src/main/webapp/app/settings/styles/settings.css +++ b/guacamole/src/main/webapp/app/settings/styles/settings.css @@ -17,10 +17,6 @@ * under the License. */ -.settings .header { - margin-bottom: 0; -} - .settings table.properties th { text-align: left; font-weight: normal; diff --git a/guacamole/src/main/webapp/app/settings/templates/settings.html b/guacamole/src/main/webapp/app/settings/templates/settings.html index eec1f9799..b29d809c8 100644 --- a/guacamole/src/main/webapp/app/settings/templates/settings.html +++ b/guacamole/src/main/webapp/app/settings/templates/settings.html @@ -1,7 +1,7 @@
-
+

{{'SETTINGS.SECTION_HEADER_SETTINGS' | translate}}

diff --git a/guacamole/src/main/webapp/translations/en.json b/guacamole/src/main/webapp/translations/en.json index a2e3792cb..92e5de00d 100644 --- a/guacamole/src/main/webapp/translations/en.json +++ b/guacamole/src/main/webapp/translations/en.json @@ -303,9 +303,11 @@ "INFO_READ_ONLY" : "Sorry, but this user account cannot be edited.", - "SECTION_HEADER_CONNECTIONS" : "Connections", - "SECTION_HEADER_EDIT_USER" : "Edit User", - "SECTION_HEADER_PERMISSIONS" : "Permissions", + "SECTION_HEADER_ALL_CONNECTIONS" : "All Connections", + "SECTION_HEADER_CONNECTIONS" : "Connections", + "SECTION_HEADER_CURRENT_CONNECTIONS" : "Current Connections", + "SECTION_HEADER_EDIT_USER" : "Edit User", + "SECTION_HEADER_PERMISSIONS" : "Permissions", "TEXT_CONFIRM_DELETE" : "Users cannot be restored after they have been deleted. Are you sure you want to delete this user?"