[KEYCLOAK-14046] Include groups in user creation via Admin Console (#7035)

* [KEYCLOAK-14046] Include groups in user creation via Admin Console

Since the POST /users API now supports providing groups membership, here is the UI
part!

- Added a field in the user creation UI to specify groups the newly created user
will be joining
- Added associated messages in english language

* Added UI integration tests

* Fixed UI tests

* Flatten nested groups in user creation groups searchbox

* Filtering out searched groups

* Removed unused injection

* Fixed UI tests

Co-authored-by: Clement Cureau <clement.cureau@cdiscount.com>
This commit is contained in:
Clement Cureau 2021-03-19 13:55:45 +01:00 committed by GitHub
parent cbb118c013
commit 0b68f24a09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 464 additions and 4 deletions

View file

@ -93,7 +93,7 @@ public abstract class AbstractMultipleSelect2<R> {
}
for (WebElement result : result) {
if (result.getText().equalsIgnoreCase(id)) {
if (match(result.getText(), id)) {
clickLink(result);
return;
}
@ -102,6 +102,10 @@ public abstract class AbstractMultipleSelect2<R> {
protected abstract Function<R, String> identity();
protected boolean match(String result, String search) {
return result != null && result.equalsIgnoreCase(search);
};
public Set<R> getSelected() {
Set<R> values = new HashSet<>();

View file

@ -0,0 +1,23 @@
package org.keycloak.testsuite.console.page.groups;
import org.jboss.arquillian.graphene.page.Page;
import org.keycloak.testsuite.console.page.AdminConsoleCreate;
/**
*
* @author clementcur
*/
public class CreateGroup extends AdminConsoleCreate {
public CreateGroup() {
setEntity("group");
}
@Page
private CreateGroupForm form;
public CreateGroupForm form() {
return form;
}
}

View file

@ -0,0 +1,36 @@
package org.keycloak.testsuite.console.page.groups;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.testsuite.page.Form;
import org.keycloak.testsuite.util.UIUtils;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.ui.Select;
import static org.keycloak.testsuite.util.UIUtils.getTextFromElement;
import static org.keycloak.testsuite.util.UIUtils.getTextInputValue;
import static org.keycloak.testsuite.util.WaitUtils.*;
/**
*
* @author clementcur
*/
public class CreateGroupForm extends Form {
@FindBy(id = "name")
private WebElement groupNameInput;
public void setValues(GroupRepresentation group) {
waitUntilElement(groupNameInput).is().present();
setGroupName(group.getName());
}
public String getGroupName() {
return getTextInputValue(groupNameInput);
}
public void setGroupName(String groupName) {
UIUtils.setTextInputValue(groupNameInput, groupName);
}
}

View file

@ -0,0 +1,56 @@
/*
* JBoss, Home of Professional Open Source
*
* Copyright 2013 Red Hat, Inc. and/or its affiliates.
*
* Licensed 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.
*/
package org.keycloak.testsuite.console.page.groups;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.keycloak.testsuite.console.page.AdminConsoleRealm;
import org.keycloak.testsuite.console.page.fragment.DataTable;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.FindBy;
/**
*
* @author clementcur
*/
public class Groups extends AdminConsoleRealm {
@Override
public String getUriFragment() {
return super.getUriFragment() + "/groups";
}
public static final String NEW_GROUP = "New";
@FindBy(id = "group-table")
private GroupsTable table;
public GroupsTable table() {
return table;
}
public class GroupsTable extends DataTable {
@Drone
private WebDriver driver;
public void addGroup() {
clickHeaderButton(NEW_GROUP);
}
}
}

View file

@ -5,12 +5,18 @@ import org.keycloak.testsuite.console.page.fragment.MultipleStringSelect2;
import org.keycloak.testsuite.console.page.fragment.OnOffSwitch;
import org.keycloak.testsuite.page.Form;
import org.keycloak.testsuite.util.UIUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.keycloak.testsuite.util.UIUtils.getTextFromElement;
import static org.keycloak.testsuite.util.WaitUtils.waitUntilElement;
/**
@ -41,6 +47,9 @@ public class UserAttributesForm extends Form {
@FindBy(xpath = ".//div[@class='onoffswitch' and ./input[@id='emailVerified']]")
private OnOffSwitch emailVerifiedSwitch;
@FindBy(id = "s2id_groups")
private GroupSelect groupsInput;
@FindBy(id = "s2id_reqActions")
private MultipleStringSelect2 requiredUserActionsSelect;
@ -103,6 +112,8 @@ public class UserAttributesForm extends Form {
emailVerifiedSwitch.setOn(emailVerified);
}
public void setGroups(Set<String> groups) { groupsInput.update(groups); }
public void addRequiredAction(String requiredAction) {
requiredUserActionsSelect.select(requiredAction);
}
@ -121,8 +132,45 @@ public class UserAttributesForm extends Form {
setLastName(user.getLastName());
if (user.isEnabled() != null) setEnabled(user.isEnabled());
if (user.isEmailVerified() != null) setEmailVerified(user.isEmailVerified());
if (user.getGroups() != null && user.getGroups().size() > 0) setGroups(new HashSet<String>(user.getGroups()));
if (user.getRequiredActions() != null) setRequiredActions(new HashSet<>(user.getRequiredActions()));
}
// TODO Contact Information section
public class GroupSelect extends MultipleStringSelect2 {
@Override
protected List<WebElement> getSelectedElements() {
return getRoot().findElements(By.xpath("(//table[@id='selected-groups'])/tbody/tr")).stream()
.filter(webElement -> webElement.findElements(By.tagName("td")).size() > 1)
.collect(Collectors.toList());
}
@Override
protected BiFunction<WebElement, String, Boolean> deselect() {
return (webElement, name) -> {
List<WebElement> tds = webElement.findElements(By.tagName("td"));
if (!getTextFromElement(tds.get(0)).isEmpty()) {
if (getTextFromElement(tds.get(0)).equals(name)) {
tds.get(1).findElement(By.tagName("button")).click();
return true;
}
}
return false;
};
}
@Override
protected Function<WebElement, String> representation() {
return webElement -> getTextFromElement(webElement.findElements(By.tagName("td")).get(0));
}
@Override
protected boolean match(String result, String search) {
return result != null && result.equalsIgnoreCase("/" + search);
}
}
}

View file

@ -0,0 +1,40 @@
package org.keycloak.testsuite.console.groups;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.keycloak.admin.client.resource.GroupResource;
import org.keycloak.admin.client.resource.GroupsResource;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.testsuite.console.AbstractConsoleTest;
import org.keycloak.testsuite.console.page.groups.CreateGroup;
import org.keycloak.testsuite.console.page.groups.Groups;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlEquals;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
/**
*
* @author clementcur
*/
public abstract class AbstractGroupTest extends AbstractConsoleTest {
@Page
protected Groups groupsPage;
@Page
protected CreateGroup createGroupPage;
protected GroupRepresentation newTestRealmGroup;
@Before
public void beforeGroupTest() {
newTestRealmGroup = new GroupRepresentation();
}
public void createGroup(GroupRepresentation group) {
assertCurrentUrlEquals(groupsPage);
groupsPage.table().addGroup();
assertCurrentUrlStartsWith(createGroupPage);
createGroupPage.form().setValues(group);
createGroupPage.form().save();
}
}

View file

@ -0,0 +1,48 @@
/*
* JBoss, Home of Professional Open Source
*
* Copyright 2013 Red Hat, Inc. and/or its affiliates.
*
* Licensed 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.
*/
package org.keycloak.testsuite.console.groups;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.testsuite.console.page.groups.CreateGroup;
import static org.junit.Assert.*;
/**
*
* @author clementcur
*/
public class GroupTest extends AbstractGroupTest {
@Page
private CreateGroup createGroupPage;
@Before
public void beforeCreateGroupTest() {
groupsPage.navigateTo();
}
@Test
public void createAGroup() {
newTestRealmGroup.setName("mygroup");
createGroup(newTestRealmGroup);
assertAlertSuccess();
}
}

View file

@ -22,10 +22,18 @@ import org.junit.Before;
import org.junit.Test;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.testsuite.console.page.users.UserAttributes;
import org.keycloak.testsuite.console.page.groups.CreateGroup;
import org.keycloak.testsuite.console.page.groups.Groups;
import java.util.ArrayList;
import java.util.Arrays;
import static org.junit.Assert.*;
import static org.keycloak.testsuite.admin.Users.setPasswordFor;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlEquals;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
/**
*
@ -37,6 +45,12 @@ public class UserAttributesTest extends AbstractUserTest {
@Page
private UserAttributes userAttributesPage;
@Page
protected Groups groupsPage;
@Page
protected CreateGroup createGroupPage;
@Before
public void beforeUserAttributesTest() {
usersPage.navigateTo();
@ -99,4 +113,31 @@ public class UserAttributesTest extends AbstractUserTest {
// TODO try to log in
}
@Test
public void createUserWithGroups() {
GroupRepresentation newGroup = new GroupRepresentation();
newGroup.setName("mygroup");
// navigate to Groups creation page
groupsPage.navigateTo();
assertCurrentUrlEquals(groupsPage);
groupsPage.table().addGroup();
assertCurrentUrlStartsWith(createGroupPage);
// create the group
createGroupPage.form().setValues(newGroup);
createGroupPage.form().save();
assertAlertSuccess();
// navigate to Users creation page
usersPage.navigateTo();
RealmRepresentation representation = testRealmResource().toRepresentation();
representation.setRegistrationEmailAsUsername(true);
testRealmResource().update(representation);
newTestRealmUser.setEmail("test-with-groups@keycloak.org");
newTestRealmUser.setGroups(Arrays.asList("mygroup"));
createUser(newTestRealmUser);
assertAlertSuccess();
}
}

View file

@ -1412,6 +1412,11 @@ unlock-user=Unlock user
federation-link=Federation Link
email-verified=Email Verified
email-verified.tooltip=Has the user's email been verified?
groups-joining=Groups
groups-joining.tooltip=Groups the user will be joining. To add a group, search for any existing one and select it.
groups-joining-select.placeholder=Select existing group
groups-joining-no-selected=No group selected
groups-joining-path=Path
required-user-actions=Required User Actions
required-user-actions.tooltip=Require an action when the user logs in. 'Verify email' sends an email to the user to verify their email address. 'Update profile' requires user to enter in new personal information. 'Update password' requires user to enter in a new password. 'Configure OTP' requires setup of a mobile password generator.
locale=Locale

View file

@ -385,14 +385,15 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser
Components,
UserImpersonation, RequiredActions,
UserStorageOperations,
$location, $http, Dialog, Notifications, $translate) {
$location, $http, Dialog, Notifications, $translate, Groups) {
$scope.realm = realm;
$scope.create = !user.id;
$scope.editUsername = $scope.create || $scope.realm.editUsernameAllowed;
$scope.emailAsUsername = $scope.realm.registrationEmailAsUsername;
$scope.groupSearch = { selectedGroup : null };
if ($scope.create) {
$scope.user = { enabled: true, attributes: {} }
$scope.user = { enabled: true, attributes: {}, groups: [] }
} else {
if (!user.attributes) {
user.attributes = {}
@ -464,6 +465,8 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser
convertAttributeValuesToLists();
if ($scope.create) {
pushSelectedGroupsToUser();
User.save({
realm: realm.realm
}, $scope.user, function (data, headers) {
@ -513,6 +516,51 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser
}
}
function pushSelectedGroupsToUser() {
var groups = $scope.user.groups;
if ($scope.selectedGroups) {
for (i = 0; i < $scope.selectedGroups.length; i++) {
var groupPath = $scope.selectedGroups[i].path;
if (!groups.includes(groupPath)) {
groups.push(groupPath);
}
}
}
}
function bfs(tree, collection) {
if (!tree["subGroups"] || tree["subGroups"].length === 0) return;
for (var i=0; i < tree["subGroups"].length; i++) {
var child = tree["subGroups"][i]
collection.push(child);
bfs(child, collection);
}
return;
}
function flattenGroups(groups) {
var flattenedGroups = [];
if (!groups || groups.length === 0) return groups;
for (var i=0; i < groups.length; i++) {
flattenedGroups.push(groups[i]);
bfs(groups[i], flattenedGroups);
}
return flattenedGroups;
}
/**
* Only keep groups that :
* - include the search term in their path
* - are not already selected
*/
function filterSearchedGroups(groups, term, selectedGroups) {
if (!groups || groups.length === 0) return groups;
if (!selectedGroups) selectedGroups = [];
return groups.filter(group => group.path?.includes(term) && !selectedGroups.some(selGroup => selGroup.id === group.id));
}
$scope.reset = function() {
$scope.user = angular.copy(user);
$scope.changed = false;
@ -530,6 +578,67 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser
$scope.removeAttribute = function(key) {
delete $scope.user.attributes[key];
}
$scope.groupsUiSelect = {
minimumInputLength: 1,
delay: 500,
allowClear: true,
query: function (query) {
var data = {results: []};
if ('' == query.term.trim()) {
query.callback(data);
return;
}
$scope.query = {
realm: realm.realm,
search: query.term.trim(),
max : 20,
first : 0
};
Groups.query($scope.query, function(response) {
data.results = filterSearchedGroups(flattenGroups(response), query.term.trim(), $scope.selectedGroups);
query.callback(data);
});
},
formatResult: function(object, container, query) {
object.text = object.path;
return object.path;
}
};
$scope.removeGroup = function(list, group) {
for (i = 0; i < angular.copy(list).length; i++) {
if (group.id == list[i].id) {
list.splice(i, 1);
}
}
}
$scope.selectGroup = function(group) {
if (!group || !group.id) {
return;
}
$scope.groupSearch.selectedGroup = group;
if (!$scope.selectedGroups) {
$scope.selectedGroups = [];
}
for (i = 0; i < $scope.selectedGroups.length; i++) {
if ($scope.selectedGroups[i].id == group.id) {
return;
}
}
$scope.selectedGroups.push(group);
$scope.groupSearch.selectedGroup = null;
}
$scope.clearGroupSelection = function() {
$scope.groupSearch.selectedGroup = null;
$('#groups').val(null).trigger('change.select2');
}
});
module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, $location, RequiredActions, User, UserExecuteActionsEmail,

View file

@ -1,7 +1,7 @@
<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
<kc-tabs-group-list></kc-tabs-group-list>
<table class="table table-striped table-bordered" style="margin-bottom: 0">
<table class="table table-striped table-bordered" id="group-table" style="margin-bottom: 0">
<thead>
<tr>
<th class="kc-table-actions" colspan="5">

View file

@ -97,6 +97,42 @@
</div>
<kc-tooltip>{{:: 'email-verified.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-if="create">
<label class="col-md-2 control-label" for="selected-groups">{{:: 'groups-joining' | translate}}</label>
<div class="col-sm-6">
<table class="table table-striped table-bordered" style="margin-top: 0px" id="selected-groups">
<thead>
<tr>
<th class="kc-table-actions" colspan="2">
<div class="form-inline col-md-12 select2-container-single">
<div class="form-group">
<div class="input-group">
<input type="hidden" ui-select2="groupsUiSelect" id="groups" data-ng-change="selectGroup(groupSearch.selectedGroup);" data-ng-model="groupSearch.selectedGroup" data-placeholder="{{:: 'groups-joining-select.placeholder' | translate}}..."/>
</div>
</div>
</div>
</th>
</tr>
<tr data-ng-hide="!selectedGroups || selectedGroups.length == 0">
<th>{{:: 'groups-joining-path' | translate}}</th>
<th width="20%">{{:: 'actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="group in selectedGroups">
<td>{{group.path}}</td>
<td class="kc-action-cell" ng-click="removeGroup(selectedGroups, group);" style="vertical-align: middle">
{{:: 'remove' | translate}}
</td>
</tr>
<tr data-ng-show="!selectedGroups || selectedGroups.length == 0">
<td class="text-muted" colspan="2">{{:: 'groups-joining-no-selected' | translate}}</td>
</tr>
</tbody>
</table>
</div>
<kc-tooltip>{{:: 'groups-joining.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="reqActions">{{:: 'required-user-actions' | translate}}</label>

View file

@ -190,6 +190,20 @@ th.w-40 {
height: 26px;
}
.select2-container-single {
padding: 0;
margin: 0;
}
.select2-container-single .form-group {
width: 100%;
margin: 0;
}
.select2-container-single .form-group .input-group {
width: 100%;
}
/*********** html select ********/
.overflow-select {
overflow: auto;