From 5436f9781cceb20f7cac5a4c95bb01578e5b7b23 Mon Sep 17 00:00:00 2001 From: huyenvu2101 Date: Thu, 15 May 2025 18:32:33 +0700 Subject: [PATCH] Allow setting default value for userprofile attribute Closes #36160 Signed-off-by: huyenvu2101 --- .../idm/UserProfileAttributeMetadata.java | 8 +++- .../userprofile/config/UPAttribute.java | 15 ++++++- .../topics/users/user-profile.adoc | 4 ++ js/apps/account-ui/src/api/representations.ts | 1 + .../admin/messages/messages_en.properties | 2 + .../realm-settings/NewAttributeSettings.tsx | 4 ++ .../attribute/AttributeGeneralSettings.tsx | 5 +++ .../src/defs/userProfileMetadata.ts | 2 + .../src/user-profile/MultiInputComponent.tsx | 1 + .../src/user-profile/OptionsComponent.tsx | 2 +- .../src/user-profile/SelectComponent.tsx | 2 +- .../src/user-profile/TextAreaComponent.tsx | 1 + .../src/user-profile/TextComponent.tsx | 1 + .../userprofile/DefaultAttributes.java | 7 +++- .../keycloak/userprofile/UserProfileUtil.java | 3 +- .../userprofile/AttributeMetadata.java | 11 +++++ .../model/AbstractUserProfileBean.java | 4 ++ .../DeclarativeUserProfileProvider.java | 2 + .../userprofile/config/UPConfigUtils.java | 10 +++++ .../user/profile/UserProfileTest.java | 42 +++++++++++++++++++ .../login/user-profile-commons.ftl | 36 ++++++++++------ 21 files changed, 142 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java b/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java index 2911ab70a20..12b829af383 100644 --- a/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java @@ -31,13 +31,14 @@ public class UserProfileAttributeMetadata { private Map> validators; private String group; private boolean multivalued; + private String defaultValue; public UserProfileAttributeMetadata() { } public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, String group, Map annotations, - Map> validators, boolean multivalued) { + Map> validators, boolean multivalued, String defaultValue) { this.name = name; this.displayName = displayName; this.required = required; @@ -46,12 +47,17 @@ public class UserProfileAttributeMetadata { this.validators = validators; this.group = group; this.multivalued = multivalued; + this.defaultValue = defaultValue; } public String getName() { return name; } + public String getDefaultValue() { + return defaultValue; + } + /** * @return display name, either direct string to display, or construct for i18n like ${i18nkey} */ diff --git a/core/src/main/java/org/keycloak/representations/userprofile/config/UPAttribute.java b/core/src/main/java/org/keycloak/representations/userprofile/config/UPAttribute.java index be01faf8ae9..cf322dcb753 100644 --- a/core/src/main/java/org/keycloak/representations/userprofile/config/UPAttribute.java +++ b/core/src/main/java/org/keycloak/representations/userprofile/config/UPAttribute.java @@ -44,6 +44,7 @@ public class UPAttribute implements Cloneable { private UPAttributeSelector selector; private String group; private boolean multivalued; + private String defaultValue; public UPAttribute() { } @@ -152,13 +153,21 @@ public class UPAttribute implements Cloneable { this.multivalued = multivalued; } + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + public boolean isMultivalued() { return multivalued; } @Override public String toString() { - return "UPAttribute [name=" + name + ", displayName=" + displayName + ", permissions=" + permissions + ", selector=" + selector + ", required=" + required + ", validations=" + validations + ", annotations=" + annotations + ", group=" + group + ", multivalued=" + multivalued + "]"; + return "UPAttribute [name=" + name + ", displayName=" + displayName + ", permissions=" + permissions + ", selector=" + selector + ", required=" + required + ", validations=" + validations + ", annotations=" + annotations + ", group=" + group + ", multivalued=" + multivalued + ", defaultValue=" + defaultValue + "]"; } @Override @@ -184,6 +193,7 @@ public class UPAttribute implements Cloneable { attr.setSelector(this.selector == null ? null : this.selector.clone()); attr.setGroup(this.group); attr.setMultivalued(this.multivalued); + attr.setDefaultValue(this.defaultValue); return attr; } @@ -209,6 +219,7 @@ public class UPAttribute implements Cloneable { && Objects.equals(this.required, other.required) && Objects.equals(this.permissions, other.permissions) && Objects.equals(this.selector, other.selector) - && Objects.equals(this.multivalued, other.multivalued); + && Objects.equals(this.multivalued, other.multivalued) + && Objects.equals(this.defaultValue, other.defaultValue); } } diff --git a/docs/documentation/server_admin/topics/users/user-profile.adoc b/docs/documentation/server_admin/topics/users/user-profile.adoc index c4343efe7de..2f9ca1a973d 100644 --- a/docs/documentation/server_admin/topics/users/user-profile.adoc +++ b/docs/documentation/server_admin/topics/users/user-profile.adoc @@ -160,6 +160,10 @@ Multivalued:: If enabled, the attribute supports multiple values and UIs are rendered accordingly to allow setting many values. When enabling this setting, make sure to add a validator to set a hard limit to the number of values. +Default Value:: +Defines the value that will be automatically assigned to the attribute if the user does not provide one. +Ensure that this default value complies with all configured validators for the attribute. + Attribute Group:: The attribute group to which the attribute belongs to, if any. diff --git a/js/apps/account-ui/src/api/representations.ts b/js/apps/account-ui/src/api/representations.ts index 944c0dd49de..357100dbc3e 100644 --- a/js/apps/account-ui/src/api/representations.ts +++ b/js/apps/account-ui/src/api/representations.ts @@ -87,6 +87,7 @@ export interface UserProfileAttributeMetadata { annotations?: { [index: string]: any }; validators: { [index: string]: { [index: string]: any } }; multivalued: boolean; + defaultValue: string; } export interface UserProfileMetadata { diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index e475a06b500..94456c68412 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3156,6 +3156,8 @@ bruteForceMode=Brute Force Mode error-invalid-multivalued-size=Attribute {{0}} must have at least {{1}} and at most {{2}} value(s). multivalued=Multivalued multivaluedHelp=If this attribute supports multiple values. This setting is an indicator and does not enable any validation. +defaultValue=Default value +defaultValueHelp=Default value when attribute value is not specified. to the attribute. For that, make sure to use any of the built-in validators to properly validate the size and the values. sendIdTokenOnLogout=Send 'id_token_hint' in logout requests sendIdTokenOnLogoutHelp=If the 'id_token_hint' parameter should be sent in logout requests. diff --git a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx index 1d48f0d079c..69adbc37e59 100644 --- a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx @@ -149,6 +149,7 @@ export default function NewAttributeSettings() { selector, required, multivalued, + defaultValue, ...values } = config.attributes!.find( (attribute) => attribute.name === attributeName, @@ -180,6 +181,7 @@ export default function NewAttributeSettings() { ); form.setValue("isRequired", required !== undefined); form.setValue("multivalued", multivalued === true); + form.setValue("defaultValue", defaultValue); }, [], ); @@ -229,6 +231,7 @@ export default function NewAttributeSettings() { annotations, validations, }, + formFields.defaultValue ? { defaultValue: formFields.defaultValue } : { defaultValue: null }, formFields.isRequired ? { required: formFields.required } : undefined, formFields.group ? { group: formFields.group } : { group: null }, ); @@ -247,6 +250,7 @@ export default function NewAttributeSettings() { annotations, validations, }, + formFields.defaultValue ? { defaultValue: formFields.defaultValue } : { defaultValue: null }, formFields.isRequired ? { required: formFields.required } : undefined, formFields.group ? { group: formFields.group } : undefined, ), diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx index 9e911330a5d..70ab12502f0 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx @@ -139,6 +139,11 @@ export const AttributeGeneralSettings = () => { label={t("multivalued")} labelIcon={t("multivaluedHelp")} /> + ; validators?: Record>; multivalued?: boolean; + defaultValue?: string; } export interface UserProfileAttributeGroupMetadata { diff --git a/js/libs/ui-shared/src/user-profile/MultiInputComponent.tsx b/js/libs/ui-shared/src/user-profile/MultiInputComponent.tsx index ace70f10c08..d5f8909cffe 100644 --- a/js/libs/ui-shared/src/user-profile/MultiInputComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/MultiInputComponent.tsx @@ -29,6 +29,7 @@ export const MultiInputComponent = ({ form={form} aria-label={labelAttribute(t, attribute)} name={fieldName(attribute.name)!} + defaultValue={[attribute.defaultValue || ""]} addButtonLabel={t("addMultivaluedLabel", { fieldLabel: labelAttribute(t, attribute), })} diff --git a/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx b/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx index 77f222fc911..4ed73819f21 100644 --- a/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx @@ -27,7 +27,7 @@ export const OptionComponent = (props: UserProfileFieldProps) => { ( <> {options.map((option) => ( diff --git a/js/libs/ui-shared/src/user-profile/SelectComponent.tsx b/js/libs/ui-shared/src/user-profile/SelectComponent.tsx index 546ecd95d7c..3a3b342a676 100644 --- a/js/libs/ui-shared/src/user-profile/SelectComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/SelectComponent.tsx @@ -66,7 +66,7 @@ export const SelectComponent = (props: UserProfileFieldProps) => { ( { rows={attribute.annotations?.["inputTypeRows"] as number} readOnly={attribute.readOnly} isRequired={isRequired} + value={attribute.defaultValue} /> ); diff --git a/js/libs/ui-shared/src/user-profile/TextComponent.tsx b/js/libs/ui-shared/src/user-profile/TextComponent.tsx index 327d2281295..439a26ba0ba 100644 --- a/js/libs/ui-shared/src/user-profile/TextComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/TextComponent.tsx @@ -31,6 +31,7 @@ export const TextComponent = (props: UserProfileFieldProps) => { } isDisabled={attribute.readOnly} isRequired={isRequired} + value={attribute.defaultValue} {...form.register(fieldName(attribute.name))} /> 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 c1964366191..03aae6e89e4 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 @@ -410,14 +410,17 @@ public class DefaultAttributes extends HashMap> implements } } - // the profile should always hold all attributes defined in the config + // the profile should always hold all attributes defined in the config and set default value if needed for (String attributeName : metadataByAttribute.keySet()) { if (!isSupportedAttribute(attributeName) || newAttributes.containsKey(attributeName)) { continue; } - List values = EMPTY_VALUE; AttributeMetadata metadata = metadataByAttribute.get(attributeName); + List values = EMPTY_VALUE; + if (metadata.getDefaultValue() != null) { + values = Collections.singletonList(metadata.getDefaultValue()); + } if (user != null && isIncludeAttributeIfNotProvided(metadata)) { values = normalizeAttributeValues(attributeName, user.getAttributes().getOrDefault(attributeName, EMPTY_VALUE)); 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 233b8514bcb..b1e4c72dc05 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 @@ -164,7 +164,8 @@ public class UserProfileUtil { group, attributes.getAnnotations(am.getName()), toValidatorMetadata(am, session), - am.isMultivalued()); + am.isMultivalued(), + am.getDefaultValue()); } private static Map> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){ 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 17ce4994fff..ec2919aca47 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java @@ -53,6 +53,7 @@ public class AttributeMetadata { private Map annotations; private int guiOrder; private boolean multivalued; + private String defaultValue; private Function> annotationDecorator = (c) -> c.getMetadata().getAnnotations(); AttributeMetadata(String attributeName, int guiOrder) { @@ -116,6 +117,10 @@ public class AttributeMetadata { return attributeName; } + public String getDefaultValue() { + return defaultValue; + } + public int getGuiOrder() { return guiOrder; } @@ -226,6 +231,7 @@ public class AttributeMetadata { cloned.setAttributeGroupMetadata(attributeGroupMetadata.clone()); } cloned.setMultivalued(multivalued); + cloned.setDefaultValue(defaultValue); cloned.setAnnotationDecorator(annotationDecorator); return cloned; } @@ -273,6 +279,11 @@ public class AttributeMetadata { return this; } + public AttributeMetadata setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + public Map getAnnotations(AttributeContext context) { return annotationDecorator.apply(context); } 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 50d975c6083..bb141db353b 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 @@ -151,6 +151,10 @@ public abstract class AbstractUserProfileBean { return metadata.isMultivalued(); } + public String getDefaultValue() { + return metadata.getDefaultValue(); + } + public String getValue() { List v = getValues(); if (v == null || v.isEmpty()) { diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index f590b3fff55..1ac6bf41baa 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -398,6 +398,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { .addWriteCondition(writeAllowed) .addValidators(validators) .setRequired(required) + .setDefaultValue(attrConfig.getDefaultValue()) .setMultivalued(attrConfig.isMultivalued()); } } else { @@ -405,6 +406,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { .addAnnotations(annotations) .setAttributeDisplayName(attrConfig.getDisplayName()) .setAttributeGroupMetadata(groupMetadata) + .setDefaultValue(attrConfig.getDefaultValue()) .setMultivalued(attrConfig.isMultivalued()); } } 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 b26155463bb..ddda2618808 100644 --- a/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java @@ -45,6 +45,7 @@ import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.userprofile.UserProfileConstants; import org.keycloak.util.JsonSerialization; +import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationResult; import org.keycloak.validate.ValidatorConfig; import org.keycloak.validate.Validators; @@ -184,6 +185,15 @@ public class UPConfigUtils { } if (attributeConfig.getValidations() != null) { attributeConfig.getValidations().forEach((validator, validatorConfig) -> validateValidationConfig(session, validator, validatorConfig, attributeName, errors)); + + if (attributeConfig.getDefaultValue() != null) { + attributeConfig.getValidations().forEach((validator, validatorConfig) -> { + ValidationContext context = Validators.validator(session, validator).validate(attributeConfig.getDefaultValue(), attributeName, ValidatorConfig.configFromMap(validatorConfig)); + if (!context.isValid()) { + errors.add("Default value is invalid"); + } + }); + } } if (attributeConfig.getPermissions() != null) { if (attributeConfig.getPermissions().getView() != null) { 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 38b5a0acb39..38de84e4128 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 @@ -2396,6 +2396,48 @@ public class UserProfileTest extends AbstractUserProfileTest { getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testMultivalued); } + @Test + public void testDefaultValue() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testInvalidConfigDefaultValue); + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testDefaultValue); + } + + private static void testInvalidConfigDefaultValue(KeycloakSession session) { + UserProfileProvider provider = getUserProfileProvider(session); + UPConfig upConfig = UPConfigUtils.parseSystemDefaultConfig(); + provider.setConfiguration(upConfig); + + UPAttribute foo = new UPAttribute("foo", new UPAttributePermissions(Set.of(), Set.of(UserProfileConstants.ROLE_ADMIN))); + foo.setDefaultValue("def"); + foo.setValidations(Map.of("length", Map.of("min", "5", "max", "15"))); + upConfig.addOrReplaceAttribute(foo); + + try { + provider.setConfiguration(upConfig); + fail("Should fail because default value is not reach min length"); + } catch (ComponentValidationException cve) { + //ignore + } + } + + private static void testDefaultValue(KeycloakSession session) { + UserProfileProvider provider = getUserProfileProvider(session); + UPConfig upConfig = UPConfigUtils.parseSystemDefaultConfig(); + UPAttribute foo = new UPAttribute("foo", new UPAttributePermissions(Set.of(), Set.of(UserProfileConstants.ROLE_ADMIN))); + foo.setDefaultValue("def"); + upConfig.addOrReplaceAttribute(foo); + provider.setConfiguration(upConfig); + + String userName = org.keycloak.models.utils.KeycloakModelUtils.generateId(); + Map> attributes = new HashMap<>(); + attributes.put(UserModel.USERNAME, List.of(userName)); + UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); + UserModel user = profile.create(); + List actualValue = user.getAttributes().get("foo"); + List expectedValue = List.of("def"); + assertThat(actualValue, Matchers.equalTo(expectedValue)); + } + private static void testMultivalued(KeycloakSession session) { UserProfileProvider provider = getUserProfileProvider(session); UPConfig upConfig = UPConfigUtils.parseSystemDefaultConfig(); diff --git a/themes/src/main/resources/theme/keycloak.v2/login/user-profile-commons.ftl b/themes/src/main/resources/theme/keycloak.v2/login/user-profile-commons.ftl index 8fc5a0f1fb9..2cc84cb8991 100644 --- a/themes/src/main/resources/theme/keycloak.v2/login/user-profile-commons.ftl +++ b/themes/src/main/resources/theme/keycloak.v2/login/user-profile-commons.ftl @@ -81,7 +81,7 @@ <@inputTag attribute=attribute value=value!''/> <#else> - <@inputTag attribute=attribute value=attribute.value!''/> + <@inputTag attribute=attribute value=(attribute.value!attribute.defaultValue!'')/> @@ -129,7 +129,7 @@ <#if attribute.annotations.inputTypeCols??>cols="${attribute.annotations.inputTypeCols}" <#if attribute.annotations.inputTypeRows??>rows="${attribute.annotations.inputTypeRows}" <#if attribute.annotations.inputTypeMaxlength??>maxlength="${attribute.annotations.inputTypeMaxlength}" - >${(attribute.value!'')} + >${(attribute.value!attribute.defaultValue!'')} @@ -153,8 +153,13 @@ <#assign options=[]> + <#assign selectedValues = attribute.values![]> + <#if !selectedValues?has_content && (attribute.defaultValue??)> + <#assign selectedValues = [attribute.defaultValue]> + + <#list options as option> - + @@ -200,16 +205,21 @@ <#assign options=[]> - <#list options as option> -
- disabled - <#if attribute.values?seq_contains(option)>checked - /> - -
- + <#assign selectedValues = attribute.values![]> + <#if !selectedValues?has_content && (attribute.defaultValue??)> + <#assign selectedValues = [attribute.defaultValue]> + + + <#list options as option> +
+ disabled + <#if selectedValues?seq_contains(option)>checked + /> + +
+ <#macro selectOptionLabelText attribute option>