KEYCLOAK-18552

* added group as attribute metadata
* validation for groups and references to groups
* adapted template to use show attribute groups
* test and integration tests for attribute groups
This commit is contained in:
Joerg Matysiak 2021-06-29 16:02:52 +02:00 committed by Pedro Igor
parent 579302f396
commit 9dff21d0a7
18 changed files with 727 additions and 18 deletions

View file

@ -0,0 +1,89 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.userprofile;
import java.util.HashMap;
import java.util.Map;
/**
* Configuration of the attribute group.
*
* @author <a href="joerg.matysiak@bosch.io">Jörg Matysiak</a>
*/
public class AttributeGroupMetadata {
private String name;
private String displayHeader;
private String displayDescription;
private Map<String, Object> annotations;
public AttributeGroupMetadata(String name, String displayHeader, String displayDescription, Map<String, Object> annotations) {
this.name = name;
this.displayHeader = displayHeader;
this.displayDescription = displayDescription;
if (annotations != null) {
addAnnotations(annotations);
}
}
public String getName() {
return name;
}
public AttributeGroupMetadata setName(String name) {
this.name = name != null ? name.trim() : null;
return this;
}
public String getDisplayHeader() {
return displayHeader;
}
public AttributeGroupMetadata setDisplayHeader(String displayHeader) {
this.displayHeader = displayHeader;
return this;
}
public String getDisplayDescription() {
return displayDescription;
}
public AttributeGroupMetadata setDisplayDescription(String displayDescription) {
this.displayDescription = displayDescription;
return this;
}
public Map<String, Object> getAnnotations() {
return annotations;
}
public AttributeGroupMetadata addAnnotations(Map<String, Object> annotations) {
if(annotations != null) {
if(this.annotations == null) {
this.annotations = new HashMap<>();
}
this.annotations.putAll(annotations);
}
return this;
}
public AttributeGroupMetadata clone() {
return new AttributeGroupMetadata(name, displayHeader, displayDescription, annotations);
}
}

View file

@ -43,6 +43,7 @@ public final class AttributeMetadata {
private final String attributeName;
private String attributeDisplayName;
private AttributeGroupMetadata attributeGroupMetadata;
private final Predicate<AttributeContext> selector;
private final Predicate<AttributeContext> writeAllowed;
/** Predicate to decide if attribute is required, it is handled as required if predicate is null */
@ -112,6 +113,10 @@ public final class AttributeMetadata {
return this;
}
public AttributeGroupMetadata getAttributeGroupMetadata() {
return attributeGroupMetadata;
}
public boolean isSelected(AttributeContext context) {
return selector.test(context);
}
@ -184,6 +189,9 @@ public final class AttributeMetadata {
cloned.addAnnotations(annotations);
}
cloned.setAttributeDisplayName(attributeDisplayName);
if (attributeGroupMetadata != null) {
cloned.setAttributeGroupMetadata(attributeGroupMetadata.clone());
}
return cloned;
}
@ -199,6 +207,12 @@ public final class AttributeMetadata {
return this;
}
public AttributeMetadata setAttributeGroupMetadata(AttributeGroupMetadata attributeGroupMetadata) {
if(attributeGroupMetadata != null)
this.attributeGroupMetadata = attributeGroupMetadata;
return this;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View file

@ -44,7 +44,7 @@ public abstract class AbstractUserProfileBean {
}
/**
* Create UserProfile instance of the relevant type. Is called from {@link #init(KeycloakSession)}.
* Create UserProfile instance of the relevant type. Is called from {@link #init(KeycloakSession, boolean)}.
*
* @param provider to create UserProfile from
* @return user profile instance
@ -158,6 +158,36 @@ public abstract class AbstractUserProfileBean {
return metadata.getValidators().stream().collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
}
public String getGroup() {
if (metadata.getAttributeGroupMetadata() != null) {
return metadata.getAttributeGroupMetadata().getName();
}
return null;
}
public String getGroupDisplayHeader() {
if (metadata.getAttributeGroupMetadata() != null) {
return metadata.getAttributeGroupMetadata().getDisplayHeader();
}
return null;
}
public String getGroupDisplayDescription() {
if (metadata.getAttributeGroupMetadata() != null) {
return metadata.getAttributeGroupMetadata().getDisplayDescription();
}
return null;
}
public Map<String, Object> getGroupAnnotations() {
if ((metadata.getAttributeGroupMetadata() == null) || (metadata.getAttributeGroupMetadata().getAnnotations() == null)) {
return Collections.emptyMap();
}
return metadata.getAttributeGroupMetadata().getAnnotations();
}
@Override
public int compareTo(Attribute o) {
return Integer.compare(metadata.getGuiOrder(), o.metadata.getGuiOrder());

View file

@ -229,7 +229,7 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
* Sub-types can override this method to customize how contextual profile metadata is configured at runtime.
*
* @param metadata the profile metadata
* @param metadata the current session
* @param session the current session
* @return the metadata
*/
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) {

View file

@ -32,6 +32,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.AmphibianProviderFactory;
@ -53,6 +54,7 @@ import org.keycloak.userprofile.config.UPAttributeRequired;
import org.keycloak.userprofile.config.UPAttributeSelector;
import org.keycloak.userprofile.config.UPConfig;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.userprofile.config.UPGroup;
import org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator;
import org.keycloak.userprofile.validator.BlankAttributeValidator;
import org.keycloak.userprofile.validator.ImmutableAttributeValidator;
@ -255,7 +257,9 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
return decoratedMetadata;
}
Map<String, UPGroup> groupsByName = asHashMap(parsedConfig.getGroups());
int guiOrder = 0;
for (UPAttribute attrConfig : parsedConfig.getAttributes()) {
String attributeName = attrConfig.getName();
List<AttributeValidatorMetadata> validators = new ArrayList<>();
@ -313,6 +317,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
Map<String, Object> annotations = attrConfig.getAnnotations();
String attributeGroup = attrConfig.getGroup();
AttributeGroupMetadata groupMetadata = toAttributeGroupMeta(groupsByName.get(attributeGroup));
if (isUsernameOrEmailAttribute(attributeName)) {
if (permissions == null) {
@ -324,16 +330,26 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
if (atts.isEmpty()) {
// attribute metadata doesn't exist so we have to add it. We keep it optional as Abstract base
// doesn't require it.
decoratedMetadata.addAttribute(attributeName, guiOrder++, writeAllowed, validators).addAnnotations(annotations).setAttributeDisplayName(attrConfig.getDisplayName());
decoratedMetadata.addAttribute(attributeName, guiOrder++, writeAllowed, validators)
.addAnnotations(annotations)
.setAttributeDisplayName(attrConfig.getDisplayName())
.setAttributeGroupMetadata(groupMetadata);
} else {
final int localGuiOrder = guiOrder++;
// only add configured validators and annotations if attribute metadata exist
atts.stream().forEach(c -> c.addValidator(validators).addAnnotations(annotations).setAttributeDisplayName(attrConfig.getDisplayName()).setGuiOrder(localGuiOrder));
atts.stream().forEach(c -> c.addValidator(validators)
.addAnnotations(annotations)
.setAttributeDisplayName(attrConfig.getDisplayName())
.setGuiOrder(localGuiOrder)
.setAttributeGroupMetadata(groupMetadata));
}
} else {
// always add validation for imuttable/read-only attributes
// always add validation for immutable/read-only attributes
validators.add(new AttributeValidatorMetadata(ImmutableAttributeValidator.ID));
decoratedMetadata.addAttribute(attributeName, guiOrder++, validators, selector, writeAllowed, required, readAllowed).addAnnotations(annotations).setAttributeDisplayName(attrConfig.getDisplayName());
decoratedMetadata.addAttribute(attributeName, guiOrder++, validators, selector, writeAllowed, required, readAllowed)
.addAnnotations(annotations)
.setAttributeDisplayName(attrConfig.getDisplayName())
.setAttributeGroupMetadata(groupMetadata);
}
}
@ -341,6 +357,17 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
private Map<String, UPGroup> asHashMap(List<UPGroup> groups) {
return groups.stream().collect(Collectors.toMap(g -> g.getName(), g -> g));
}
private AttributeGroupMetadata toAttributeGroupMeta(UPGroup group) {
if (group == null) {
return null;
}
return new AttributeGroupMetadata(group.getName(), group.getDisplayHeader(), group.getDisplayDescription(), group.getAnnotations());
}
private boolean isUsernameOrEmailAttribute(String attributeName) {
return UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName);
}

View file

@ -38,6 +38,7 @@ public class UPAttribute {
private UPAttributePermissions permissions;
/** null means it is always selected */
private UPAttributeSelector selector;
private String group;
public String getName() {
return name;
@ -102,8 +103,16 @@ public class UPAttribute {
this.displayName = displayName;
}
public String getGroup() {
return group;
}
public void setGroup(String group) {
this.group = group != null ? group.trim() : null;
}
@Override
public String toString() {
return "UPAttribute [name=" + name + ", displayName=" + displayName + ", permissions=" + permissions + ", selector=" + selector + ", required=" + required + ", validations=" + validations + ", annotations=" + annotations + "]";
return "UPAttribute [name=" + name + ", displayName=" + displayName + ", permissions=" + permissions + ", selector=" + selector + ", required=" + required + ", validations=" + validations + ", annotations=" + annotations + ", group=" + group + "]";
}
}

View file

@ -17,6 +17,7 @@
package org.keycloak.userprofile.config;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
@ -28,6 +29,7 @@ import java.util.List;
public class UPConfig {
private List<UPAttribute> attributes;
private List<UPGroup> groups;
public List<UPAttribute> getAttributes() {
return attributes;
@ -47,8 +49,29 @@ public class UPConfig {
return this;
}
public List<UPGroup> getGroups() {
if (groups == null) {
return Collections.emptyList();
}
return groups;
}
public void setGroups(List<UPGroup> groups) {
this.groups = groups;
}
public UPConfig addGroup(UPGroup group) {
if (groups == null) {
groups = new ArrayList<>();
}
groups.add(group);
return this;
}
@Override
public String toString() {
return "UPConfig [attributes=" + attributes + "]";
return "UPConfig [attributes=" + attributes + ", groups=" + groups + "]";
}
}

View file

@ -22,11 +22,13 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.common.util.StreamUtil;
@ -62,7 +64,7 @@ public class UPConfigUtils {
/**
* Load configuration from JSON file.
* <p>
* Configuration is not validated, use {@link #validate(UPConfig)} to validate it and get list of errors.
* Configuration is not validated, use {@link #validate(KeycloakSession, UPConfig)} to validate it and get list of errors.
*
* @param is JSON file to be loaded
* @return object representation of the configuration
@ -75,10 +77,12 @@ public class UPConfigUtils {
/**
* Validate object representation of the configuration. Validations:
* <ul>
* <li>defaultProfile is defined and exists in profiles
* <li>parent exists for type
* <li>type exists for attribute
* <li>validator (from Validator SPI) exists for validation and it's config is correct
* <li>defaultProfile is defined and exists in profiles</li>
* <li>parent exists for type</li>
* <li>type exists for attribute</li>
* <li>validator (from Validator SPI) exists for validation and it's config is correct</li>
* <li>if an attribute group is configured it is verified that this group exists</li>
* <li>all groups have a name != null</li>
* </ul>
*
* @param session to be used for Validator SPI integration
@ -86,11 +90,28 @@ public class UPConfigUtils {
* @return list of errors, empty if no error found
*/
public static List<String> validate(KeycloakSession session, UPConfig config) {
List<String> errors = validateAttributes(session, config);
errors.addAll(validateAttributeGroups(config));
return errors;
}
private static List<String> validateAttributeGroups(UPConfig config) {
long groupsWithoutName = config.getGroups().stream().filter(g -> g.getName() == null).collect(Collectors.counting());
if (groupsWithoutName > 0) {
String errorMessage = "Name is mandatory for groups, found " + groupsWithoutName + " group(s) without name.";
return Collections.singletonList(errorMessage);
}
return Collections.emptyList();
}
private static List<String> validateAttributes(KeycloakSession session, UPConfig config) {
List<String> errors = new ArrayList<>();
Set<String> groups = config.getGroups().stream().map(g -> g.getName()).collect(Collectors.toSet());
if (config.getAttributes() != null) {
Set<String> attNamesCache = new HashSet<>();
config.getAttributes().forEach((attribute) -> validate(session, attribute, errors, attNamesCache));
config.getAttributes().forEach((attribute) -> validateAttribute(session, attribute, groups, errors, attNamesCache));
} else {
errors.add("UserProfile configuration without 'attributes' section is not allowed");
}
@ -103,10 +124,11 @@ public class UPConfigUtils {
*
* @param session to be used for Validator SPI integration
* @param attributeConfig config to be validated
* @param groups set of groups that are configured
* @param errors to add error message in if something is invalid
* @param attNamesCache cache of already existing attribute names so we can check uniqueness
*/
private static void validate(KeycloakSession session, UPAttribute attributeConfig, List<String> errors, Set<String> attNamesCache) {
private static void validateAttribute(KeycloakSession session, UPAttribute attributeConfig, Set<String> groups, List<String> errors, Set<String> attNamesCache) {
String attributeName = attributeConfig.getName();
if (isBlank(attributeName)) {
errors.add("Attribute configuration without 'name' is not allowed");
@ -138,6 +160,12 @@ public class UPConfigUtils {
if (attributeConfig.getSelector() != null) {
validateScopes(attributeConfig.getSelector().getScopes(), "selector.scopes", attributeName, errors, session);
}
if (attributeConfig.getGroup() != null) {
if (!groups.contains(attributeConfig.getGroup())) {
errors.add("Attribute '" + attributeName + "' references unknown group '" + attributeConfig.getGroup() + "'");
}
}
}
private static void validateScopes(Set<String> scopes, String propertyName, String attributeName, List<String> errors, KeycloakSession session) {

View file

@ -0,0 +1,65 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.userprofile.config;
import java.util.Map;
/**
* Configuration of the attribute group.
*
* @author <a href="joerg.matysiak@bosch.io">Jörg Matysiak</a>
*/
public class UPGroup {
private String name;
private String displayHeader;
private String displayDescription;
private Map<String, Object> annotations;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name != null ? name.trim() : null;
}
public String getDisplayHeader() {
return displayHeader;
}
public void setDisplayHeader(String displayHeader) {
this.displayHeader = displayHeader;
}
public String getDisplayDescription() {
return displayDescription;
}
public void setDisplayDescription(String displayDescription) {
this.displayDescription = displayDescription;
}
public Map<String, Object> getAnnotations() {
return annotations;
}
public void setAnnotations(Map<String, Object> annotations) {
this.annotations = annotations;
}
}

View file

@ -113,6 +113,70 @@ public class RequiredActionUpdateProfileWithUserProfileTest extends RequiredActi
}
@Test
public void testAttributeGrouping() {
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"username\", " + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"department\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\":{}, \"group\": \"company\"},"
+ "{\"name\": \"email\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"group\": \"contact\"}"
+ "], \"groups\": ["
+ "{\"name\": \"company\", \"displayDescription\": \"Company field desc\" },"
+ "{\"name\": \"contact\" }"
+ "]}");
loginPage.open();
loginPage.login(USERNAME1, PASSWORD);
updateProfilePage.assertCurrent();
String htmlFormId="kc-update-profile-form";
//assert fields and groups location in form
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(2) > input#firstName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(1) > label#header-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > label#description-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#department")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-contact")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#email")
).isDisplayed()
);
}
@Test
public void testAttributeGuiOrder() {

View file

@ -72,6 +72,74 @@ public class KcOidcFirstBrokerLoginWithUserProfileTest extends KcOidcFirstBroker
Assert.assertEquals("Department", updateAccountInformationPage.getLabelForField("department"));
}
@Test
public void testAttributeGrouping() {
updateExecutions(AbstractBrokerTest::enableUpdateProfileOnFirstLogin);
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"username\", " + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"department\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\":{}, \"group\": \"company\"},"
+ "{\"name\": \"email\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"group\": \"contact\"}"
+ "], \"groups\": ["
+ "{\"name\": \"company\", \"displayDescription\": \"Company field desc\" },"
+ "{\"name\": \"contact\" }"
+ "]}");
driver.navigate().to(getAccountUrl(getConsumerRoot(), bc.consumerRealmName()));
logInWithBroker(bc);
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
//assert fields location in form
String htmlFormId = "kc-idp-review-profile-form";
//assert fields and groups location in form
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(2) > input#firstName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(1) > label#header-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > label#description-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#department")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-contact")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#email")
).isDisplayed()
);
}
@Test
public void testAttributeGuiOrder() {

View file

@ -294,6 +294,79 @@ public class RegisterWithUserProfileTest extends RegisterTest {
);
}
@Test
public void testAttributeGrouping() {
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"username\", " + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"department\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\":{}, \"group\": \"company\"},"
+ "{\"name\": \"email\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"group\": \"contact\"}"
+ "], \"groups\": ["
+ "{\"name\": \"company\", \"displayDescription\": \"Company field desc\" },"
+ "{\"name\": \"contact\" }"
+ "]}");
loginPage.open();
loginPage.clickRegister();
registerPage.assertCurrent();
String htmlFormId="kc-register-form";
//assert fields and groups location in form
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#kc-register-form > div:nth-child(3) > div:nth-child(2) > input#password")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > input#password-confirm")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#firstName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(2) > label#description-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#department")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(8) > div:nth-child(1) > label#header-contact")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(9) > div:nth-child(2) > input#email")
).isDisplayed()
);
}
@Test
public void testRegisterUserSuccess_requiredReadOnlyAttributeNotRenderedAndNotBlockingRegistration() {

View file

@ -180,6 +180,72 @@ public class VerifyProfileTest extends AbstractTestRealmKeycloakTest {
Assert.assertEquals("Department",verifyProfilePage.getLabelForField("department"));
}
@Test
public void testAttributeGrouping() {
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
updateUser(user5Id, "ExistingFirst", "ExistingLast", null);
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"lastName\"," + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"username\", " + VerifyProfileTest.PERMISSIONS_ALL + "},"
+ "{\"name\": \"firstName\"," + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"department\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"required\":{}, \"group\": \"company\"},"
+ "{\"name\": \"email\", " + VerifyProfileTest.PERMISSIONS_ALL + ", \"group\": \"contact\"}"
+ "], \"groups\": ["
+ "{\"name\": \"company\", \"displayDescription\": \"Company field desc\" },"
+ "{\"name\": \"contact\" }"
+ "]}");
loginPage.open();
loginPage.login("login-test5", "password");
verifyProfilePage.assertCurrent();
String htmlFormId="kc-update-profile-form";
//assert fields and groups location in form
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(2) > input#firstName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(1) > label#header-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > label#description-company")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#department")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-contact")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#email")
).isDisplayed()
);
}
@Test
public void testAttributeGuiOrder() {

View file

@ -54,6 +54,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.messages.Messages;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.runonserver.RunOnServer;
import org.keycloak.userprofile.AttributeGroupMetadata;
import org.keycloak.userprofile.DeclarativeUserProfileProvider;
import org.keycloak.userprofile.config.UPAttribute;
import org.keycloak.userprofile.config.UPAttributePermissions;
@ -349,6 +350,67 @@ public class UserProfileTest extends AbstractUserProfileTest {
assertNotNull(attributes.getFirstValue("address"));
}
@Test
public void testGetProfileAttributeGroups() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testGetProfileAttributeGroups);
}
private static void testGetProfileAttributeGroups(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().addUser(realm, org.keycloak.models.utils.KeycloakModelUtils.generateId());
UserProfileProvider provider = getDynamicUserProfileProvider(session);
String configuration = "{\n" +
" \"attributes\": [\n" +
" {\n" +
" \"name\": \"address\",\n" +
" \"group\": \"companyaddress\"\n" +
" },\n" +
" {\n" +
" \"name\": \"second\",\n" +
" \"group\": \"groupwithanno" + "\"\n" +
" }\n" +
" ],\n" +
" \"groups\": [\n" +
" {\n" +
" \"name\": \"companyaddress\",\n" +
" \"displayHeader\": \"header\",\n" +
" \"displayDescription\": \"description\"\n" +
" },\n" +
" {\n" +
" \"name\": \"groupwithanno\",\n" +
" \"annotations\": {\n" +
" \"anno1\": \"value1\",\n" +
" \"anno2\": \"value2\"\n" +
" }\n" +
" }\n" +
" ]\n" +
"}\n";
provider.setConfiguration(configuration);
UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user);
Attributes attributes = profile.getAttributes();
assertThat(attributes.nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address", "second"));
AttributeGroupMetadata companyAddressGroup = attributes.getMetadata("address").getAttributeGroupMetadata();
assertEquals("companyaddress", companyAddressGroup.getName());
assertEquals("header", companyAddressGroup.getDisplayHeader());
assertEquals("description", companyAddressGroup.getDisplayDescription());
assertNull(companyAddressGroup.getAnnotations());
AttributeGroupMetadata groupwithannoGroup = attributes.getMetadata("second").getAttributeGroupMetadata();
assertEquals("groupwithanno", groupwithannoGroup.getName());
assertNull(groupwithannoGroup.getDisplayHeader());
assertNull(groupwithannoGroup.getDisplayDescription());
Map<String, Object> annotations = groupwithannoGroup.getAnnotations();
assertEquals(2, annotations.size());
assertEquals("value1", annotations.get("anno1"));
assertEquals("value2", annotations.get("anno2"));
}
@Test
public void testCreateAndUpdateUser() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testCreateAndUpdateUser);

View file

@ -42,9 +42,10 @@ import org.keycloak.userprofile.config.UPAttributePermissions;
import org.keycloak.userprofile.config.UPAttributeRequired;
import org.keycloak.userprofile.config.UPConfig;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.userprofile.config.UPGroup;
/**
* Unit test for {@link UPConfigParser} functionality
* Unit test for {@link UPConfigUtils} functionality
*
* @author Vlastimil Elias <velias@redhat.com>
*
@ -134,6 +135,19 @@ public class UPConfigParserTest extends AbstractTestRealmKeycloakTest {
//displayName
att = config.getAttributes().get(4);
Assert.assertEquals("${profile.phone}", att.getDisplayName());
// group
Assert.assertEquals("contact", att.getGroup());
// assert *** groups ***
Assert.assertEquals(1, config.getGroups().size());
UPGroup group = config.getGroups().get(0);
Assert.assertEquals("contact", group.getName());
Assert.assertEquals("Contact information", group.getDisplayHeader());
Assert.assertEquals("Required to contact you in case of emergency", group.getDisplayDescription());
Assert.assertEquals(1, group.getAnnotations().size());
Assert.assertEquals("value1", group.getAnnotations().get("contactanno1"));
}
/**
@ -309,4 +323,37 @@ public class UPConfigParserTest extends AbstractTestRealmKeycloakTest {
errors = validate(session, config);
Assert.assertEquals(1, errors.size());
}
@Test
public void validateConfiguration_attributeGroupConfigurationErrors() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UPConfigParserTest::validateConfiguration_attributeGroupConfigurationErrors);
}
private static void validateConfiguration_attributeGroupConfigurationErrors(KeycloakSession session) throws IOException {
UPConfig config = loadValidConfig();
// add a group without name
UPGroup groupWithoutName = new UPGroup();
config.addGroup(groupWithoutName);
List<String> errors = validate(session, config);
Assert.assertEquals(1, errors.size());
Assert.assertEquals("Name is mandatory for groups, found 1 group(s) without name.", errors.get(0));
}
@Test
public void validateConfiguration_attributeGroupReferenceErrors() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UPConfigParserTest::validateConfiguration_attributeGroupReferenceErrors);
}
private static void validateConfiguration_attributeGroupReferenceErrors(KeycloakSession session) throws IOException {
UPConfig config = loadValidConfig();
// attribute references group that is not configured
UPAttribute firstAttribute = config.getAttributes().get(0);
firstAttribute.setGroup("non-existing-group");
List<String> errors = validate(session, config);
Assert.assertEquals(1, errors.size());
Assert.assertEquals("Attribute 'username' references unknown group 'non-existing-group'", errors.get(0));
}
}

View file

@ -1,4 +1,14 @@
{
"groups" : [
{
"name" : "contact",
"displayHeader" : "Contact information",
"displayDescription" : "Required to contact you in case of emergency",
"annotations" : {
"contactanno1" : "value1"
}
}
],
"attributes": [
{
"name":"username",
@ -46,6 +56,7 @@
"validations": {
"not-blank":{}
},
"group": "contact",
"required": {
"scopes" : ["phone-1", "phone-2"],
"roles" : ["user", "admin"]

View file

@ -1,5 +1,35 @@
<#macro userProfileFormFields>
<#assign currentGroup="">
<#list profile.attributes as attribute>
<#assign groupName = attribute.group!"">
<#if groupName != currentGroup>
<#assign currentGroup=groupName>
<#if currentGroup != "" >
<div class="${properties.kcFormGroupClass!}">
<#assign groupDisplayHeader=attribute.groupDisplayHeader!"">
<#if groupDisplayHeader != "">
<#assign groupHeaderText=advancedMsg(attribute.groupDisplayHeader)!groupName>
<#else>
<#assign groupHeaderText=groupName>
</#if>
<div class="${properties.kcContentWrapperClass!}">
<label id="header-${groupName}" class="${kcFormGroupHeader!}">${groupHeaderText}</label>
</div>
<#assign groupDisplayDescription=attribute.groupDisplayDescription!"">
<#if groupDisplayDescription != "">
<#assign groupDescriptionText=advancedMsg(attribute.groupDisplayDescription)!"">
<div class="${properties.kcLabelWrapperClass!}">
<label id="description-${groupName}" class="${properties.kcLabelClass!}">${groupDescriptionText}</label>
</div>
</#if>
</div>
</#if>
</#if>
<#nested "beforeField" attribute>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">

View file

@ -68,6 +68,9 @@ kcSignUpClass=login-pf-signup
kcInfoAreaClass=col-xs-12 col-sm-4 col-md-4 col-lg-5 details
### user-profile grouping
kcFormGroupHeader=pf-c-form__group
##### css classes for form buttons
# main class used for all buttons
kcButtonClass=pf-c-button