diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeGroupMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeGroupMetadata.java new file mode 100644 index 0000000000..7c7ee16df2 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeGroupMetadata.java @@ -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 Jörg Matysiak + */ +public class AttributeGroupMetadata { + + private String name; + private String displayHeader; + private String displayDescription; + private Map annotations; + + public AttributeGroupMetadata(String name, String displayHeader, String displayDescription, Map 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 getAnnotations() { + return annotations; + } + + public AttributeGroupMetadata addAnnotations(Map 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); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java index f84ece4aab..b4717024dd 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java @@ -43,6 +43,7 @@ public final class AttributeMetadata { private final String attributeName; private String attributeDisplayName; + private AttributeGroupMetadata attributeGroupMetadata; private final Predicate selector; private final Predicate 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; diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java index 3ebacf573a..208b87dd5e 100644 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java @@ -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 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()); diff --git a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java index a4dd7bc339..d0b8e85541 100644 --- a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java @@ -229,7 +229,7 @@ public abstract class AbstractUserProfileProvider * 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) { diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index 5c20c1a1f7..2d9a09ab77 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -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 groupsByName = asHashMap(parsedConfig.getGroups()); int guiOrder = 0; + for (UPAttribute attrConfig : parsedConfig.getAttributes()) { String attributeName = attrConfig.getName(); List validators = new ArrayList<>(); @@ -313,6 +317,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< } Map 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 asHashMap(List 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); } diff --git a/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java b/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java index d9d3aa2b0a..648fc9e244 100644 --- a/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPAttribute.java @@ -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 + "]"; } } diff --git a/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java b/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java index 3d1154b6cd..ca21d79a8f 100644 --- a/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPConfig.java @@ -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 attributes; + private List groups; public List getAttributes() { return attributes; @@ -47,8 +49,29 @@ public class UPConfig { return this; } + public List getGroups() { + if (groups == null) { + return Collections.emptyList(); + } + return groups; + } + + public void setGroups(List 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 + "]"; } } diff --git a/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java b/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java index e1cf65c207..406ac5756e 100644 --- a/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java @@ -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. *

- * 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: *

    - *
  • defaultProfile is defined and exists in profiles - *
  • parent exists for type - *
  • type exists for attribute - *
  • validator (from Validator SPI) exists for validation and it's config is correct + *
  • defaultProfile is defined and exists in profiles
  • + *
  • parent exists for type
  • + *
  • type exists for attribute
  • + *
  • validator (from Validator SPI) exists for validation and it's config is correct
  • + *
  • if an attribute group is configured it is verified that this group exists
  • + *
  • all groups have a name != null
  • *
* * @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 validate(KeycloakSession session, UPConfig config) { - List errors = new ArrayList<>(); + List errors = validateAttributes(session, config); + errors.addAll(validateAttributeGroups(config)); + return errors; + } + + private static List 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 validateAttributes(KeycloakSession session, UPConfig config) { + List errors = new ArrayList<>(); + Set groups = config.getGroups().stream().map(g -> g.getName()).collect(Collectors.toSet()); + if (config.getAttributes() != null) { Set 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 errors, Set attNamesCache) { + private static void validateAttribute(KeycloakSession session, UPAttribute attributeConfig, Set groups, List errors, Set 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 scopes, String propertyName, String attributeName, List errors, KeycloakSession session) { diff --git a/services/src/main/java/org/keycloak/userprofile/config/UPGroup.java b/services/src/main/java/org/keycloak/userprofile/config/UPGroup.java new file mode 100644 index 0000000000..d2e28b71c5 --- /dev/null +++ b/services/src/main/java/org/keycloak/userprofile/config/UPGroup.java @@ -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 Jörg Matysiak + */ +public class UPGroup { + + private String name; + private String displayHeader; + private String displayDescription; + private Map 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 getAnnotations() { + return annotations; + } + + public void setAnnotations(Map annotations) { + this.annotations = annotations; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateProfileWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateProfileWithUserProfileTest.java index e1ee18d182..479ecb48e3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateProfileWithUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionUpdateProfileWithUserProfileTest.java @@ -112,7 +112,71 @@ public class RequiredActionUpdateProfileWithUserProfileTest extends RequiredActi Assert.assertEquals("Department",updateProfilePage.getLabelForField("department")); } - + + @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() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginWithUserProfileTest.java index ae21b616b8..d4a4900562 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginWithUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginWithUserProfileTest.java @@ -71,6 +71,74 @@ public class KcOidcFirstBrokerLoginWithUserProfileTest extends KcOidcFirstBroker // direct value in display name 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() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java index 979733758c..718e568796 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterWithUserProfileTest.java @@ -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() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java index 73796f1762..54bae00107 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java @@ -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() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index 0cb29e3f27..d22bcfeaaf 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -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 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); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java index 2b039cedde..da1ffc847d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java @@ -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 * @@ -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 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 errors = validate(session, config); + Assert.assertEquals(1, errors.size()); + Assert.assertEquals("Attribute 'username' references unknown group 'non-existing-group'", errors.get(0)); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json index 290754ca5e..3cf99cfc5f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json @@ -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"] diff --git a/themes/src/main/resources/theme/base/login/user-profile-commons.ftl b/themes/src/main/resources/theme/base/login/user-profile-commons.ftl index 888d43a32b..89669a00e1 100644 --- a/themes/src/main/resources/theme/base/login/user-profile-commons.ftl +++ b/themes/src/main/resources/theme/base/login/user-profile-commons.ftl @@ -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 != "" > +
+ + <#assign groupDisplayHeader=attribute.groupDisplayHeader!""> + <#if groupDisplayHeader != ""> + <#assign groupHeaderText=advancedMsg(attribute.groupDisplayHeader)!groupName> + <#else> + <#assign groupHeaderText=groupName> + +
+ +
+ + <#assign groupDisplayDescription=attribute.groupDisplayDescription!""> + <#if groupDisplayDescription != ""> + <#assign groupDescriptionText=advancedMsg(attribute.groupDisplayDescription)!""> +
+ +
+ +
+ + + <#nested "beforeField" attribute>
diff --git a/themes/src/main/resources/theme/keycloak/login/theme.properties b/themes/src/main/resources/theme/keycloak/login/theme.properties index fd02c70f36..1d96fc668e 100644 --- a/themes/src/main/resources/theme/keycloak/login/theme.properties +++ b/themes/src/main/resources/theme/keycloak/login/theme.properties @@ -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