diff --git a/js/apps/account-ui/src/personal-info/PersonalInfo.tsx b/js/apps/account-ui/src/personal-info/PersonalInfo.tsx index c5447cbdd34..5771c636bcc 100644 --- a/js/apps/account-ui/src/personal-info/PersonalInfo.tsx +++ b/js/apps/account-ui/src/personal-info/PersonalInfo.tsx @@ -119,11 +119,15 @@ export const PersonalInfo = () => { ((key: unknown, params) => t(key as TFuncKey, params as any)) as TFunction } - renderer={(attribute) => - attribute.name === "email" && - updateEmailFeatureEnabled && - updateEmailActionEnabled && - (!isRegistrationEmailAsUsername || isEditUserNameAllowed) ? ( + renderer={(attribute) => { + const annotations = attribute.annotations + ? attribute.annotations + : {}; + return attribute.name === "email" && + updateEmailFeatureEnabled && + updateEmailActionEnabled && + annotations["kc.required.action.supported"] && + (!isRegistrationEmailAsUsername || isEditUserNameAllowed) ? ( - ) : undefined - } + ) : undefined; + }} /> {!allFieldsReadOnly() && ( diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java index f96271155e4..c1964366191 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java @@ -567,6 +567,19 @@ public class DefaultAttributes extends HashMap> implements return unmanagedAttributes; } + @Override + public Map getAnnotations(String name) { + AttributeMetadata metadata = getMetadata(name); + + if (metadata == null) { + return Collections.emptyMap(); + } + + AttributeContext context = createAttributeContext(metadata); + + return metadata.getAnnotations(context); + } + protected AttributeMetadata createUnmanagedAttributeMetadata(String name) { return new AttributeMetadata(name, Integer.MAX_VALUE) { final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java index 6c28ddd784e..233b8514bcb 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java @@ -155,12 +155,14 @@ public class UserProfileUtil { group = am.getAttributeGroupMetadata().getName(); } + Attributes attributes = profile.getAttributes(); + return new UserProfileAttributeMetadata(am.getName(), am.getAttributeDisplayName(), - profile.getAttributes().isRequired(am.getName()), - profile.getAttributes().isReadOnly(am.getName()), + attributes.isRequired(am.getName()), + attributes.isReadOnly(am.getName()), group, - am.getAnnotations(), + attributes.getAnnotations(am.getName()), toValidatorMetadata(am, session), am.isMultivalued()); } diff --git a/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java b/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java index 016e0ec7f32..17ce4994fff 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java @@ -24,6 +24,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -52,7 +53,7 @@ public class AttributeMetadata { private Map annotations; private int guiOrder; private boolean multivalued; - + private Function> annotationDecorator = (c) -> c.getMetadata().getAnnotations(); AttributeMetadata(String attributeName, int guiOrder) { this(attributeName, guiOrder, ALWAYS_TRUE, ALWAYS_TRUE, ALWAYS_TRUE, ALWAYS_TRUE); @@ -225,6 +226,7 @@ public class AttributeMetadata { cloned.setAttributeGroupMetadata(attributeGroupMetadata.clone()); } cloned.setMultivalued(multivalued); + cloned.setAnnotationDecorator(annotationDecorator); return cloned; } @@ -270,4 +272,13 @@ public class AttributeMetadata { this.validators = validators; return this; } + + public Map getAnnotations(AttributeContext context) { + return annotationDecorator.apply(context); + } + + public AttributeMetadata setAnnotationDecorator(Function> annotationDecorator) { + this.annotationDecorator = annotationDecorator; + return this; + } } diff --git a/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java b/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java index a55da0e31bc..cd63955a7ee 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; import org.keycloak.validate.ValidationError; @@ -168,4 +169,24 @@ public interface Attributes { * @return a map with any unmanaged attribute */ Map> getUnmanagedAttributes(); + + /** + *

Returns the annotations for an attribute with the given {@code name}. + * + *

The annotations returned by this method might differ from those returned directly from + * the {@link AttributeMetadata#getAnnotations()} if the implementation supports annotations + * being resolved dynamically based on contextual data. See {@link AttributeMetadata#setAnnotationDecorator(Function)}. + * + * @param name the name of the attribute + * @return the annotations + */ + default Map getAnnotations(String name) { + AttributeMetadata metadata = getMetadata(name); + + if (metadata == null) { + return Collections.emptyMap(); + } + + return metadata.getAnnotations(); + } } diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java index 86323fbc4ce..4a20a8039c9 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java @@ -28,6 +28,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -42,13 +43,14 @@ import org.keycloak.component.ComponentValidationException; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.UserModel; import org.keycloak.organization.validator.OrganizationMemberValidator; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPAttributePermissions; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.services.messages.Messages; import org.keycloak.userprofile.config.UPConfigUtils; @@ -57,7 +59,6 @@ import org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueVali import org.keycloak.userprofile.validator.DuplicateEmailValidator; import org.keycloak.userprofile.validator.DuplicateUsernameValidator; import org.keycloak.userprofile.validator.EmailExistsAsUsernameValidator; -import org.keycloak.userprofile.validator.ImmutableAttributeValidator; import org.keycloak.userprofile.validator.ReadOnlyAttributeUnchangedValidator; import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidator; import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameValueValidator; @@ -67,6 +68,7 @@ import org.keycloak.userprofile.validator.UsernameMutationValidator; import org.keycloak.validate.ValidatorConfig; import org.keycloak.validate.validators.EmailValidator; +import static java.util.Optional.ofNullable; import static org.keycloak.common.util.ObjectUtil.isBlank; import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY; import static org.keycloak.userprofile.UserProfileContext.ACCOUNT; @@ -412,7 +414,8 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide new AttributeValidatorMetadata(DuplicateEmailValidator.ID), new AttributeValidatorMetadata(EmailExistsAsUsernameValidator.ID), new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build())) - .setAttributeDisplayName("${email}"); + .setAttributeDisplayName("${email}") + .setAnnotationDecorator(DeclarativeUserProfileProviderFactory::getEmailAnnotationDecorator); List readonlyValidators = new ArrayList<>(); @@ -485,7 +488,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide // The user-defined configuration is always parsed during init and should be avoided as much as possible // If no user-defined configuration is set, the system default configuration must have been set // In Quarkus, the system default configuration is set at build time for optimization purposes - UPConfig defaultConfig = Optional.ofNullable(config.get("configFile")) + UPConfig defaultConfig = ofNullable(config.get("configFile")) .map(Paths::get) .map(UPConfigUtils::parseConfig) .orElse(PARSED_DEFAULT_RAW_CONFIG); @@ -498,4 +501,36 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide PARSED_DEFAULT_RAW_CONFIG = null; setDefaultConfig(defaultConfig); } + + private static Map getEmailAnnotationDecorator(AttributeContext c) { + AttributeMetadata m = c.getMetadata(); + Map rawAnnotations = Optional.ofNullable(m.getAnnotations()).orElse(Map.of()); + + if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) { + UserProfileProvider provider = c.getSession().getProvider(UserProfileProvider.class); + UPConfig upConfig = provider.getConfiguration(); + UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL); + UPAttributePermissions permissions = attribute.getPermissions(); + + if (permissions == null) { + return rawAnnotations; + } + + Set writePermissions = permissions.getEdit(); + boolean isWritable = writePermissions.contains(UPConfigUtils.ROLE_USER); + RealmModel realm = c.getSession().getContext().getRealm(); + + if ((realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) || !isWritable) { + return rawAnnotations; + } + + Map annotations = new HashMap<>(rawAnnotations); + + annotations.put("kc.required.action.supported", isWritable); + + return annotations; + } + + return rawAnnotations; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java index 0d4287c0f1f..5db6912239e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java @@ -16,6 +16,9 @@ */ package org.keycloak.testsuite.account; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertNotNull; @@ -25,6 +28,8 @@ import static org.keycloak.testsuite.account.AccountRestServiceTest.getUserProfi import static org.keycloak.testsuite.util.userprofile.UserProfileUtil.PERMISSIONS_ALL; import static org.keycloak.testsuite.util.userprofile.UserProfileUtil.PERMISSIONS_ADMIN_EDITABLE; import static org.keycloak.testsuite.util.userprofile.UserProfileUtil.PERMISSIONS_ADMIN_ONLY; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER; import java.io.IOException; import java.util.Collections; @@ -32,10 +37,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.junit.Before; import org.junit.Test; +import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.broker.provider.util.SimpleHttp; +import org.keycloak.common.Profile; import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.models.UserModel; @@ -43,6 +51,10 @@ import org.keycloak.representations.idm.UserProfileAttributeMetadata; import org.keycloak.representations.idm.UserProfileMetadata; import org.keycloak.representations.account.UserRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPAttributePermissions; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.broker.util.SimpleHttpDefault; import org.keycloak.testsuite.util.userprofile.UserProfileUtil; import org.keycloak.userprofile.UserProfileContext; @@ -158,6 +170,33 @@ public class AccountRestServiceWithUserProfileTest extends AbstractRestServiceTe } } + @EnableFeature(value = Profile.Feature.UPDATE_EMAIL, skipRestart = true) + @Test + public void testUpdateEmailLink() throws Exception { + RealmResource realm = adminClient.realm("test"); + RealmRepresentation realmRep = realm.toRepresentation(); + + try { + realmRep.setEditUsernameAllowed(false); + realm.update(realmRep); + + UserRepresentation user = getUser(); + assertNotNull(user.getUserProfileMetadata()); + assertThat(user.getUserProfileMetadata().getAttributeMetadata(UserModel.EMAIL).getAnnotations().get("kc.required.action.supported"), is(true)); + + UPConfig upConfig = realm.users().userProfile().getConfiguration(); + UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL); + attribute.setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN))); + realm.users().userProfile().update(upConfig); + user = getUser(); + assertNotNull(user.getUserProfileMetadata()); + assertThat(user.getUserProfileMetadata().getAttributeMetadata(UserModel.EMAIL).getAnnotations().get("kc.required.action.supported"), is(nullValue())); + } finally { + realmRep.setEditUsernameAllowed(true); + realm.update(realmRep); + } + } + @Test public void testGetUserProfileMetadata_RoAccessToNameFields() throws IOException { 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 3e6d6a8aab5..aeec98ff209 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 @@ -19,8 +19,10 @@ package org.keycloak.testsuite.user.profile; +import static java.util.Optional.ofNullable; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -2311,6 +2313,77 @@ public class UserProfileTest extends AbstractUserProfileTest { } } + @EnableFeature(Feature.UPDATE_EMAIL) + @Test + public void testEmailAnnotationsInAccountContext() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testEmailAnnotationsInAccountContext); + } + + private static void testEmailAnnotationsInAccountContext(KeycloakSession session) { + UserProfileProvider provider = getUserProfileProvider(session); + String userName = org.keycloak.models.utils.KeycloakModelUtils.generateId(); + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, userName); + String originalEmail = userName + "@keycloak.org"; + attributes.put(UserModel.EMAIL, originalEmail); + attributes.put(UserModel.FIRST_NAME, "Joe"); + attributes.put(UserModel.LAST_NAME, "Doe"); + attributes.put("address", "some address"); + + UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); + UserModel user = profile.create(); + RealmModel realm = session.getContext().getRealm(); + + try { + realm.setEditUsernameAllowed(false); + realm.setRegistrationEmailAsUsername(true); + profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + assertFalse(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).containsKey("kc.required.action.supported")); + } finally { + realm.setEditUsernameAllowed(true); + realm.setRegistrationEmailAsUsername(false); + } + + try { + realm.setEditUsernameAllowed(true); + realm.setRegistrationEmailAsUsername(true); + profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + assertThat(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).get("kc.required.action.supported"), is(true)); + } finally { + realm.setEditUsernameAllowed(true); + realm.setRegistrationEmailAsUsername(false); + } + + try { + realm.setEditUsernameAllowed(false); + realm.setRegistrationEmailAsUsername(false); + UPConfig upConfig = provider.getConfiguration(); + UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL); + attribute.setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN))); + provider.setConfiguration(upConfig); + profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + assertFalse(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).containsKey("kc.required.action.supported")); + } finally { + realm.setEditUsernameAllowed(true); + realm.setRegistrationEmailAsUsername(false); + } + + try { + realm.setEditUsernameAllowed(false); + realm.setRegistrationEmailAsUsername(false); + UPConfig upConfig = provider.getConfiguration(); + UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL); + attribute.setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN, ROLE_USER))); + provider.setConfiguration(upConfig); + profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + assertThat(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).get("kc.required.action.supported"), is(true)); + } finally { + realm.setEditUsernameAllowed(true); + realm.setRegistrationEmailAsUsername(false); + } + } + @Test public void testMultivalued() { getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testMultivalued);