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 != "" >
+
+ #if>
+ #if>
+
<#nested "beforeField" attribute>