Allow setting default value for userprofile attribute

Closes #36160

Signed-off-by: huyenvu2101 <vhuyen2101@gmail.com>
This commit is contained in:
huyenvu2101 2025-05-15 18:32:33 +07:00 committed by Pedro Igor
parent 1af235c4f1
commit 5436f9781c
21 changed files with 142 additions and 21 deletions

View File

@ -31,13 +31,14 @@ public class UserProfileAttributeMetadata {
private Map<String, Map<String, Object>> 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<String, Object> annotations,
Map<String, Map<String, Object>> validators, boolean multivalued) {
Map<String, Map<String, Object>> 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 <code>${i18nkey}</code>
*/

View File

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

View File

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

View File

@ -87,6 +87,7 @@ export interface UserProfileAttributeMetadata {
annotations?: { [index: string]: any };
validators: { [index: string]: { [index: string]: any } };
multivalued: boolean;
defaultValue: string;
}
export interface UserProfileMetadata {

View File

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

View File

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

View File

@ -139,6 +139,11 @@ export const AttributeGeneralSettings = () => {
label={t("multivalued")}
labelIcon={t("multivaluedHelp")}
/>
<TextControl
name="defaultValue"
label={t("defaultValue")}
labelIcon={t("defaultValueHelp")}
/>
<SelectControl
name="group"
label={t("attributeGroup")}

View File

@ -15,6 +15,7 @@ export interface UserProfileAttribute {
displayName?: string;
group?: string;
multivalued?: boolean;
defaultValue?: string;
}
export interface UserProfileAttributeRequired {
roles?: string[];
@ -43,6 +44,7 @@ export interface UserProfileAttributeMetadata {
annotations?: Record<string, unknown>;
validators?: Record<string, Record<string, unknown>>;
multivalued?: boolean;
defaultValue?: string;
}
export interface UserProfileAttributeGroupMetadata {

View File

@ -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),
})}

View File

@ -27,7 +27,7 @@ export const OptionComponent = (props: UserProfileFieldProps) => {
<Controller
name={fieldName(attribute.name)}
control={form.control}
defaultValue=""
defaultValue={attribute.defaultValue}
render={({ field }) => (
<>
{options.map((option) => (

View File

@ -66,7 +66,7 @@ export const SelectComponent = (props: UserProfileFieldProps) => {
<UserProfileGroup {...props}>
<Controller
name={fieldName(attribute.name)}
defaultValue=""
defaultValue={attribute.defaultValue}
control={form.control}
render={({ field }) => (
<KeycloakSelect

View File

@ -17,6 +17,7 @@ export const TextAreaComponent = (props: UserProfileFieldProps) => {
rows={attribute.annotations?.["inputTypeRows"] as number}
readOnly={attribute.readOnly}
isRequired={isRequired}
value={attribute.defaultValue}
/>
</UserProfileGroup>
);

View File

@ -31,6 +31,7 @@ export const TextComponent = (props: UserProfileFieldProps) => {
}
isDisabled={attribute.readOnly}
isRequired={isRequired}
value={attribute.defaultValue}
{...form.register(fieldName(attribute.name))}
/>
</UserProfileGroup>

View File

@ -410,14 +410,17 @@ public class DefaultAttributes extends HashMap<String, List<String>> 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<String> values = EMPTY_VALUE;
AttributeMetadata metadata = metadataByAttribute.get(attributeName);
List<String> 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));

View File

@ -164,7 +164,8 @@ public class UserProfileUtil {
group,
attributes.getAnnotations(am.getName()),
toValidatorMetadata(am, session),
am.isMultivalued());
am.isMultivalued(),
am.getDefaultValue());
}
private static Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){

View File

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

View File

@ -151,6 +151,10 @@ public abstract class AbstractUserProfileBean {
return metadata.isMultivalued();
}
public String getDefaultValue() {
return metadata.getDefaultValue();
}
public String getValue() {
List<String> v = getValues();
if (v == null || v.isEmpty()) {

View File

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

View File

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

View File

@ -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<String, List<String>> attributes = new HashMap<>();
attributes.put(UserModel.USERNAME, List.of(userName));
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
UserModel user = profile.create();
List<String> actualValue = user.getAttributes().get("foo");
List<String> expectedValue = List.of("def");
assertThat(actualValue, Matchers.equalTo(expectedValue));
}
private static void testMultivalued(KeycloakSession session) {
UserProfileProvider provider = getUserProfileProvider(session);
UPConfig upConfig = UPConfigUtils.parseSystemDefaultConfig();

View File

@ -81,7 +81,7 @@
<@inputTag attribute=attribute value=value!''/>
</#list>
<#else>
<@inputTag attribute=attribute value=attribute.value!''/>
<@inputTag attribute=attribute value=(attribute.value!attribute.defaultValue!'')/>
</#if>
</#switch>
</#macro>
@ -129,7 +129,7 @@
<#if attribute.annotations.inputTypeCols??>cols="${attribute.annotations.inputTypeCols}"</#if>
<#if attribute.annotations.inputTypeRows??>rows="${attribute.annotations.inputTypeRows}"</#if>
<#if attribute.annotations.inputTypeMaxlength??>maxlength="${attribute.annotations.inputTypeMaxlength}"</#if>
>${(attribute.value!'')}</textarea>
>${(attribute.value!attribute.defaultValue!'')}</textarea>
</span>
</#macro>
@ -153,8 +153,13 @@
<#assign options=[]>
</#if>
<#assign selectedValues = attribute.values![]>
<#if !selectedValues?has_content && (attribute.defaultValue??)>
<#assign selectedValues = [attribute.defaultValue]>
</#if>
<#list options as option>
<option value="${option}" <#if attribute.values?seq_contains(option)>selected</#if>><@selectOptionLabelText attribute=attribute option=option/></option>
<option value="${option}" <#if selectedValues?seq_contains(option)>selected</#if>><@selectOptionLabelText attribute=attribute option=option/></option>
</#list>
</select>
<span class="${properties.kcFormControlUtilClass}">
@ -200,16 +205,21 @@
<#assign options=[]>
</#if>
<#list options as option>
<div class="${classDiv}">
<input type="${inputType}" id="${attribute.name}-${option}" name="${attribute.name}" value="${option}" class="${classInput}"
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
<#if attribute.readOnly>disabled</#if>
<#if attribute.values?seq_contains(option)>checked</#if>
/>
<label for="${attribute.name}-${option}" class="${classLabel}<#if attribute.readOnly> ${properties.kcInputClassRadioCheckboxLabelDisabled!}</#if>"><@selectOptionLabelText attribute=attribute option=option/></label>
</div>
</#list>
<#assign selectedValues = attribute.values![]>
<#if !selectedValues?has_content && (attribute.defaultValue??)>
<#assign selectedValues = [attribute.defaultValue]>
</#if>
<#list options as option>
<div class="${classDiv}">
<input type="${inputType}" id="${attribute.name}-${option}" name="${attribute.name}" value="${option}" class="${classInput}"
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
<#if attribute.readOnly>disabled</#if>
<#if selectedValues?seq_contains(option)>checked</#if>
/>
<label for="${attribute.name}-${option}" class="${classLabel}<#if attribute.readOnly> ${properties.kcInputClassRadioCheckboxLabelDisabled!}</#if>"><@selectOptionLabelText attribute=attribute option=option/></label>
</div>
</#list>
</#macro>
<#macro selectOptionLabelText attribute option>