From 604274fb7622b3cd5e4599d516c50cfc103f6c49 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Mon, 12 Feb 2024 13:31:11 -0300 Subject: [PATCH] Allow setting an attribute as multivalued Closes #23539 Signed-off-by: Pedro Igor Co-authored-by: Jon Koops Co-authored-by: Erik Jan de Wit --- .../idm/UserProfileAttributeMetadata.java | 11 +- .../userprofile/config/UPAttribute.java | 20 +- .../topics/users/user-profile.adoc | 62 +- js/apps/account-ui/src/api/representations.ts | 1 + .../admin/messages/messages_en.properties | 4 + .../realm-settings/NewAttributeSettings.tsx | 4 + .../attribute/AttributeGeneralSettings.tsx | 593 +++++++++--------- .../src/defs/userProfileMetadata.ts | 2 + .../src/user-profile/MultiInputComponent.tsx | 12 +- .../src/user-profile/UserProfileFields.tsx | 17 +- .../keycloak/userprofile/UserProfileUtil.java | 3 +- .../userprofile/AttributeMetadata.java | 10 + .../keycloak/validate/ValidationResult.java | 8 + .../keycloak/validate/ValidatorConfig.java | 5 + .../FreeMarkerEmailTemplateProvider.java | 2 +- .../email/freemarker/beans/ProfileBean.java | 16 +- .../model/AbstractUserProfileBean.java | 124 +++- .../DeclarativeUserProfileProvider.java | 14 +- .../validator/MultiValueValidator.java | 139 ++++ .../org.keycloak.validate.ValidatorFactory | 3 +- .../pages/LoginUpdateProfilePage.java | 45 +- .../account/AccountRestServiceTest.java | 2 +- .../RequiredActionUpdateProfileTest.java | 112 ++++ ...ctionUpdateProfileWithUserProfileTest.java | 18 +- .../keycloak/testsuite/admin/UserTest.java | 1 + .../broker/KcOidcFirstBrokerLoginTest.java | 18 +- .../federation/ldap/LDAPUserProfileTest.java | 26 +- .../forms/RegisterWithUserProfileTest.java | 29 +- .../testsuite/forms/VerifyProfileTest.java | 18 +- .../user/profile/UserProfileTest.java | 75 +++ .../validation/BuiltinValidatorsTest.java | 39 ++ .../tests/base/testsuites/forms-suite | 3 +- .../testsuite/sssd/SSSDUserProfileTest.java | 36 +- .../admin/messages/messages_en.properties | 3 +- .../login/messages/messages_en.properties | 1 + .../base/login/resources/js/kcMultivalued.js | 106 ++++ .../base/login/resources/js/kcNumberFormat.js | 27 +- .../login/resources/js/kcNumberUnFormat.js | 18 +- .../base/login/resources/js/userProfile.js | 71 +++ .../theme/base/login/user-profile-commons.ftl | 40 +- 40 files changed, 1283 insertions(+), 455 deletions(-) create mode 100644 services/src/main/java/org/keycloak/userprofile/validator/MultiValueValidator.java create mode 100644 themes/src/main/resources/theme/base/login/resources/js/kcMultivalued.js create mode 100644 themes/src/main/resources/theme/base/login/resources/js/userProfile.js 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 2c46aca7b0..2911ab70a2 100644 --- a/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserProfileAttributeMetadata.java @@ -30,13 +30,14 @@ public class UserProfileAttributeMetadata { private Map annotations; private Map> validators; private String group; + private boolean multivalued; public UserProfileAttributeMetadata() { } public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, String group, Map annotations, - Map> validators) { + Map> validators, boolean multivalued) { this.name = name; this.displayName = displayName; this.required = required; @@ -44,6 +45,7 @@ public class UserProfileAttributeMetadata { this.annotations = annotations; this.validators = validators; this.group = group; + this.multivalued = multivalued; } public String getName() { @@ -85,4 +87,11 @@ public class UserProfileAttributeMetadata { return validators; } + public void setMultivalued(boolean multivalued) { + this.multivalued = multivalued; + } + + public boolean isMultivalued() { + return multivalued; + } } 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 e283177a8d..be01faf8ae 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 @@ -43,6 +43,7 @@ public class UPAttribute implements Cloneable { /** null means it is always selected */ private UPAttributeSelector selector; private String group; + private boolean multivalued; public UPAttribute() { } @@ -71,6 +72,11 @@ public class UPAttribute implements Cloneable { this(name, permissions, null); } + public UPAttribute(String name, boolean multivalued, UPAttributePermissions permissions) { + this(name, permissions, null); + setMultivalued(multivalued); + } + public String getName() { return name; } @@ -142,9 +148,17 @@ public class UPAttribute implements Cloneable { this.group = group != null ? group.trim() : null; } + public void setMultivalued(boolean multivalued) { + this.multivalued = multivalued; + } + + 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 + "]"; + return "UPAttribute [name=" + name + ", displayName=" + displayName + ", permissions=" + permissions + ", selector=" + selector + ", required=" + required + ", validations=" + validations + ", annotations=" + annotations + ", group=" + group + ", multivalued=" + multivalued + "]"; } @Override @@ -169,6 +183,7 @@ public class UPAttribute implements Cloneable { attr.setPermissions(this.permissions == null ? null : this.permissions.clone()); attr.setSelector(this.selector == null ? null : this.selector.clone()); attr.setGroup(this.group); + attr.setMultivalued(this.multivalued); return attr; } @@ -193,6 +208,7 @@ public class UPAttribute implements Cloneable { && Objects.equals(this.annotations, other.annotations) && Objects.equals(this.required, other.required) && Objects.equals(this.permissions, other.permissions) - && Objects.equals(this.selector, other.selector); + && Objects.equals(this.selector, other.selector) + && Objects.equals(this.multivalued, other.multivalued); } } diff --git a/docs/documentation/server_admin/topics/users/user-profile.adoc b/docs/documentation/server_admin/topics/users/user-profile.adoc index d2f13fc2d6..5d1d252f8c 100644 --- a/docs/documentation/server_admin/topics/users/user-profile.adoc +++ b/docs/documentation/server_admin/topics/users/user-profile.adoc @@ -156,6 +156,10 @@ The name of the attribute, used to uniquely identify an attribute. Display name:: A user-friendly name for the attribute, mainly used when rendering user-facing forms. It also supports link:#_using-internationalized-messages[Using Internationalized Messages] +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. + Attribute Group:: The attribute group to which the attribute belongs to, if any. @@ -293,6 +297,15 @@ The list below provides a list of all the built-in validators: *error-message*: the key of the error message in i18n bundle. If not set a generic message is used. +|multivalued +|Validates the size of a multivalued attribute. +| + +*min*: an integer to define the minimum allowed count of attribute values. + +*max*: an integer to define the maximum allowed count of attribute values. + + |=== [[_defining-ui-annotations]] @@ -580,9 +593,52 @@ translate into an HTML attribute in the corresponding element of an attribute, p the same name will be loaded to the dynamic pages so that you can select elements from the DOM based on the custom `data-` attribute and decorate them accordingly by modifying their DOM representation. -For instance, if you add a `kcMyCustomValidation` annotation to a field, the HTML attribute `data-kcMyCustomValidation` is added to -the corresponding HTML element for the attribute, and a JavaScript file is loaded from your custom theme at `/resources/js/kcMyCustomValidation.js`. See the {developerguide_link}[{developerguide_name}] for more information about -how to deploy a custom JS script file to your theme. +For instance, if you add a `kcMyCustomValidation` annotation to an attribute, the HTML attribute `data-kcMyCustomValidation` is added to +the corresponding HTML element for the attribute, and a JavaScript module is loaded from your custom theme at `/resources/js/kcMyCustomValidation.js`. +See the {developerguide_link}[{developerguide_name}] for more information about how to deploy a custom JavaScript module to your theme. + +The JavaScript module can run any code to customize the DOM and the elements rendered for each attribute. For that, +you can use the `userProfile.js` module to register an annotation descriptor for your custom annotation as follows: + +[source,javascript] +---- +import { registerElementAnnotatedBy } from "./userProfile.js"; + +registerElementAnnotatedBy({ + name: 'kcMyCustomValidation', + onAdd(element) { + var listener = function (event) { + // do something on keyup + }; + + element.addEventListener("keyup", listener); + + // returns a cleanup function to remove the event listener + return () => element.removeEventListener("keyup", listener); + } +}); +---- + +The `registerElementAnnotatedBy` is a method to register annotation descriptors. A descriptor is an object with a `name`, +referencing the annotation name, +and a `onAdd` function. Whenever the page is rendered or an attribute with the annotation is added to the DOM, the `onAdd` +function is invoked so that you can customize the behavior for the element. + +The `onAdd` function can also return a function to perform a cleanup. For instance, if you are adding event listeners +to elements, you might want to remove them in case the element is removed from the DOM. + +Alternatively, you can also use any JavaScript code you want if the `userProfile.js` is not enough for your needs: + +[source,javascript] +---- +document.querySelectorAll(`[data-kcMyCustomValidation]`).forEach((element) => { + var listener = function (evt) { + // do something on keyup + }; + + element.addEventListener("keyup", listener); + }); +---- == Managing Attribute Groups diff --git a/js/apps/account-ui/src/api/representations.ts b/js/apps/account-ui/src/api/representations.ts index 224696b55a..ff5ab73307 100644 --- a/js/apps/account-ui/src/api/representations.ts +++ b/js/apps/account-ui/src/api/representations.ts @@ -81,6 +81,7 @@ export interface UserProfileAttributeMetadata { readOnly: boolean; annotations?: { [index: string]: any }; validators: { [index: string]: { [index: string]: any } }; + multivalued: boolean; } 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 ad25cb7810..e7fab68064 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 @@ -3060,3 +3060,7 @@ bruteForceMode.PermanentLockout=Lockout permanently bruteForceMode.TemporaryLockout=Lockout temporarily bruteForceMode.PermanentAfterTemporaryLockout=Lockout permanently after temporary lockout 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 +to the attribute. For that, make sure to use any of the built-in validators to properly validate the size and the values. diff --git a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx index e0eaf893b1..e9655c62e1 100644 --- a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx @@ -142,6 +142,7 @@ export default function NewAttributeSettings() { permissions, selector, required, + multivalued, ...values } = config.attributes!.find( (attribute) => attribute.name === attributeName, @@ -172,6 +173,7 @@ export default function NewAttributeSettings() { })), ); form.setValue("isRequired", required !== undefined); + form.setValue("multivalued", multivalued === true); }, [], ); @@ -217,6 +219,7 @@ export default function NewAttributeSettings() { displayName: formFields.displayName!, selector: formFields.selector, permissions: formFields.permissions!, + multivalued: formFields.multivalued, annotations, validations, }, @@ -234,6 +237,7 @@ export default function NewAttributeSettings() { required: formFields.isRequired ? formFields.required : undefined, selector: formFields.selector, permissions: formFields.permissions!, + multivalued: formFields.multivalued, annotations, validations, }, 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 6de100ba36..b3133aeef9 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 @@ -11,11 +11,17 @@ import { } from "@patternfly/react-core"; import { isEqual } from "lodash-es"; import { useState } from "react"; -import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { + Controller, + FormProvider, + useFormContext, + useWatch, +} from "react-hook-form"; import { useTranslation } from "react-i18next"; import { HelpItem } from "ui-shared"; import { adminClient } from "../../../admin-client"; +import { DefaultSwitchControl } from "../../../components/SwitchControl"; import { FormAccess } from "../../../components/form/FormAccess"; import { KeycloakSpinner } from "../../../components/keycloak-spinner/KeycloakSpinner"; import { KeycloakTextInput } from "../../../components/keycloak-text-input/KeycloakTextInput"; @@ -78,315 +84,330 @@ export const AttributeGeneralSettings = () => { return ( - - } - fieldId="kc-attribute-name" - isRequired - validated={form.formState.errors.name ? "error" : "default"} - helperTextInvalid={t("validateAttributeName")} - > - + + } + fieldId="kc-attribute-name" isRequired - id="kc-attribute-name" - defaultValue="" - data-testid="attribute-name" - isDisabled={editMode} validated={form.formState.errors.name ? "error" : "default"} - {...form.register("name", { required: true })} - /> - - + - } - fieldId="kc-attribute-display-name" - > - - - + + } + fieldId="kc-attribute-display-name" + > + - } - fieldId="kc-attributeGroup" - > - ( - + setIsAttributeGroupDropdownOpen(!isAttributeGroupDropdownOpen) + } + isOpen={isAttributeGroupDropdownOpen} + onSelect={(_, value) => { + field.onChange(value.toString()); + setIsAttributeGroupDropdownOpen(false); + }} + selections={[field.value || t("none")]} + variant={SelectVariant.single} + > + {[ + + {t("none")} + , + ...(config?.groups?.map((group) => ( + + {group.name} + + )) || []), + ]} + + )} + > + + {!USERNAME_EMAIL.includes(attributeName) && ( + <> + + } - isOpen={isAttributeGroupDropdownOpen} - onSelect={(_, value) => { - field.onChange(value.toString()); - setIsAttributeGroupDropdownOpen(false); - }} - selections={[field.value || t("none")]} - variant={SelectVariant.single} + fieldId="enabledWhen" + hasNoPaddingTop > - {[ - - {t("none")} - , - ...(config?.groups?.map((group) => ( - - {group.name} - - )) || []), - ]} - - )} - > - - {!USERNAME_EMAIL.includes(attributeName) && ( - <> - - setHasSelector(false)} + className="pf-u-mb-md" /> - } - fieldId="enabledWhen" - hasNoPaddingTop - > - setHasSelector(false)} - className="pf-u-mb-md" - /> - setHasSelector(true)} - className="pf-u-mb-md" - /> - - {hasSelector && ( - - ( - - )} + setHasSelector(true)} + className="pf-u-mb-md" /> - )} - - )} - {attributeName !== "username" && ( - <> - - - } - fieldId="kc-required" - hasNoPaddingTop - > - ( - - )} - /> - - {required && ( - <> - + {hasSelector && ( + ( -
- {REQUIRED_FOR.map((option) => ( - { - field.onChange(option.value); - }} - label={t(option.label)} - className="kc-requiredFor-option" - /> + )} /> - + )} + {attributeName !== "username" && ( + <> + + + } + fieldId="kc-required" + hasNoPaddingTop + > + ( + - } - fieldId="requiredWhen" - hasNoPaddingTop - > - setHasRequiredScopes(false)} - className="pf-u-mb-md" - /> - setHasRequiredScopes(true)} - className="pf-u-mb-md" - /> - - {hasRequiredScopes && ( - + )} + /> + + {required && ( + <> + ( - +
)} />
- )} - - )} - - )} + + } + fieldId="requiredWhen" + hasNoPaddingTop + > + setHasRequiredScopes(false)} + className="pf-u-mb-md" + /> + setHasRequiredScopes(true)} + className="pf-u-mb-md" + /> + + {hasRequiredScopes && ( + + ( + + )} + /> + + )} + + )} + + )} +
); }; diff --git a/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts b/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts index 060064cc1d..1eb67748e6 100644 --- a/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts +++ b/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts @@ -14,6 +14,7 @@ export interface UserProfileAttribute { selector?: UserProfileAttributeSelector; displayName?: string; group?: string; + multivalued?: boolean; } export interface UserProfileAttributeRequired { roles?: string[]; @@ -41,6 +42,7 @@ export interface UserProfileAttributeMetadata { group?: string; annotations?: Record; validators?: Record>; + multivalued?: boolean; } 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 d5836c3dc1..63faeebf68 100644 --- a/js/libs/ui-shared/src/user-profile/MultiInputComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/MultiInputComponent.tsx @@ -4,13 +4,14 @@ import { InputGroup, TextInput, TextInputProps, + TextInputTypes, } from "@patternfly/react-core"; import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons"; import { type TFunction } from "i18next"; import { Fragment, useEffect, useMemo } from "react"; import { FieldPath, UseFormReturn, useWatch } from "react-hook-form"; -import { UserProfileFieldProps } from "./UserProfileFields"; +import { InputType, UserProfileFieldProps } from "./UserProfileFields"; import { UserProfileGroup } from "./UserProfileGroup"; import { UserFormFields, fieldName, labelAttribute } from "./utils"; @@ -19,6 +20,7 @@ export const MultiInputComponent = ({ form, attribute, renderer, + ...rest }: UserProfileFieldProps) => ( ); @@ -40,11 +43,13 @@ export type MultiLineInputProps = Omit & { addButtonLabel?: string; isDisabled?: boolean; defaultValue?: string[]; + inputType: InputType; }; const MultiLineInput = ({ t, name, + inputType, form, addButtonLabel, isDisabled = false, @@ -84,6 +89,10 @@ const MultiLineInput = ({ }); }; + const type = inputType.startsWith("html") + ? (inputType.substring("html".length + 2) as TextInputTypes) + : "text"; + useEffect(() => { register(name); }, [register]); @@ -99,6 +108,7 @@ const MultiLineInput = ({ name={`${name}.${index}.value`} value={value} isDisabled={isDisabled} + type={type} {...rest} />