Do not show update email link if the email attribute is not writable

Closes #39669

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor
2025-06-28 05:19:41 -03:00
committed by GitHub
parent 5894ab663b
commit 304bcdce88
8 changed files with 213 additions and 15 deletions

View File

@@ -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) ? (
<Button
id="update-email-btn"
variant="link"
@@ -135,8 +139,8 @@ export const PersonalInfo = () => {
>
{t("updateEmail")}
</Button>
) : undefined
}
) : undefined;
}}
/>
{!allFieldsReadOnly() && (
<ActionGroup>

View File

@@ -567,6 +567,19 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return unmanagedAttributes;
}
@Override
public Map<String, Object> 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();

View File

@@ -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());
}

View File

@@ -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<String, Object> annotations;
private int guiOrder;
private boolean multivalued;
private Function<AttributeContext, Map<String, Object>> 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<String, Object> getAnnotations(AttributeContext context) {
return annotationDecorator.apply(context);
}
public AttributeMetadata setAnnotationDecorator(Function<AttributeContext, Map<String, Object>> annotationDecorator) {
this.annotationDecorator = annotationDecorator;
return this;
}
}

View File

@@ -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<String, List<String>> getUnmanagedAttributes();
/**
* <p>Returns the annotations for an attribute with the given {@code name}.
*
* <p>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<String, Object> getAnnotations(String name) {
AttributeMetadata metadata = getMetadata(name);
if (metadata == null) {
return Collections.emptyMap();
}
return metadata.getAnnotations();
}
}

View File

@@ -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<AttributeValidatorMetadata> 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<String, Object> getEmailAnnotationDecorator(AttributeContext c) {
AttributeMetadata m = c.getMetadata();
Map<String, Object> 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<String> 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<String, Object> annotations = new HashMap<>(rawAnnotations);
annotations.put("kc.required.action.supported", isWritable);
return annotations;
}
return rawAnnotations;
}
}

View File

@@ -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 {

View File

@@ -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<String, String> 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);