Allow setting an attribute as multivalued

Closes #23539

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>

Co-authored-by: Jon Koops <jonkoops@gmail.com>
Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Pedro Igor 2024-02-12 13:31:11 -03:00 committed by Marek Posolda
parent 1e12b15890
commit 604274fb76
40 changed files with 1283 additions and 455 deletions

View file

@ -30,13 +30,14 @@ public class UserProfileAttributeMetadata {
private Map<String, Object> annotations; private Map<String, Object> annotations;
private Map<String, Map<String, Object>> validators; private Map<String, Map<String, Object>> validators;
private String group; private String group;
private boolean multivalued;
public UserProfileAttributeMetadata() { public UserProfileAttributeMetadata() {
} }
public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, String group, Map<String, Object> annotations, public UserProfileAttributeMetadata(String name, String displayName, boolean required, boolean readOnly, String group, Map<String, Object> annotations,
Map<String, Map<String, Object>> validators) { Map<String, Map<String, Object>> validators, boolean multivalued) {
this.name = name; this.name = name;
this.displayName = displayName; this.displayName = displayName;
this.required = required; this.required = required;
@ -44,6 +45,7 @@ public class UserProfileAttributeMetadata {
this.annotations = annotations; this.annotations = annotations;
this.validators = validators; this.validators = validators;
this.group = group; this.group = group;
this.multivalued = multivalued;
} }
public String getName() { public String getName() {
@ -85,4 +87,11 @@ public class UserProfileAttributeMetadata {
return validators; return validators;
} }
public void setMultivalued(boolean multivalued) {
this.multivalued = multivalued;
}
public boolean isMultivalued() {
return multivalued;
}
} }

View file

@ -43,6 +43,7 @@ public class UPAttribute implements Cloneable {
/** null means it is always selected */ /** null means it is always selected */
private UPAttributeSelector selector; private UPAttributeSelector selector;
private String group; private String group;
private boolean multivalued;
public UPAttribute() { public UPAttribute() {
} }
@ -71,6 +72,11 @@ public class UPAttribute implements Cloneable {
this(name, permissions, null); this(name, permissions, null);
} }
public UPAttribute(String name, boolean multivalued, UPAttributePermissions permissions) {
this(name, permissions, null);
setMultivalued(multivalued);
}
public String getName() { public String getName() {
return name; return name;
} }
@ -142,9 +148,17 @@ public class UPAttribute implements Cloneable {
this.group = group != null ? group.trim() : null; this.group = group != null ? group.trim() : null;
} }
public void setMultivalued(boolean multivalued) {
this.multivalued = multivalued;
}
public boolean isMultivalued() {
return multivalued;
}
@Override @Override
public String toString() { 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 @Override
@ -169,6 +183,7 @@ public class UPAttribute implements Cloneable {
attr.setPermissions(this.permissions == null ? null : this.permissions.clone()); attr.setPermissions(this.permissions == null ? null : this.permissions.clone());
attr.setSelector(this.selector == null ? null : this.selector.clone()); attr.setSelector(this.selector == null ? null : this.selector.clone());
attr.setGroup(this.group); attr.setGroup(this.group);
attr.setMultivalued(this.multivalued);
return attr; return attr;
} }
@ -193,6 +208,7 @@ public class UPAttribute implements Cloneable {
&& Objects.equals(this.annotations, other.annotations) && Objects.equals(this.annotations, other.annotations)
&& Objects.equals(this.required, other.required) && Objects.equals(this.required, other.required)
&& Objects.equals(this.permissions, other.permissions) && Objects.equals(this.permissions, other.permissions)
&& Objects.equals(this.selector, other.selector); && Objects.equals(this.selector, other.selector)
&& Objects.equals(this.multivalued, other.multivalued);
} }
} }

View file

@ -156,6 +156,10 @@ The name of the attribute, used to uniquely identify an attribute.
Display name:: 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] 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:: Attribute Group::
The attribute group to which the attribute belongs to, if any. 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. *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]] [[_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 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. 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 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 file is loaded from your custom theme at `<THEME TYPE>/resources/js/kcMyCustomValidation.js`. See the {developerguide_link}[{developerguide_name}] for more information about the corresponding HTML element for the attribute, and a JavaScript module is loaded from your custom theme at `<THEME TYPE>/resources/js/kcMyCustomValidation.js`.
how to deploy a custom JS script file to your theme. 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 == Managing Attribute Groups

View file

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

View file

@ -3060,3 +3060,7 @@ bruteForceMode.PermanentLockout=Lockout permanently
bruteForceMode.TemporaryLockout=Lockout temporarily bruteForceMode.TemporaryLockout=Lockout temporarily
bruteForceMode.PermanentAfterTemporaryLockout=Lockout permanently after temporary lockout bruteForceMode.PermanentAfterTemporaryLockout=Lockout permanently after temporary lockout
bruteForceMode=Brute Force Mode 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.

View file

@ -142,6 +142,7 @@ export default function NewAttributeSettings() {
permissions, permissions,
selector, selector,
required, required,
multivalued,
...values ...values
} = config.attributes!.find( } = config.attributes!.find(
(attribute) => attribute.name === attributeName, (attribute) => attribute.name === attributeName,
@ -172,6 +173,7 @@ export default function NewAttributeSettings() {
})), })),
); );
form.setValue("isRequired", required !== undefined); form.setValue("isRequired", required !== undefined);
form.setValue("multivalued", multivalued === true);
}, },
[], [],
); );
@ -217,6 +219,7 @@ export default function NewAttributeSettings() {
displayName: formFields.displayName!, displayName: formFields.displayName!,
selector: formFields.selector, selector: formFields.selector,
permissions: formFields.permissions!, permissions: formFields.permissions!,
multivalued: formFields.multivalued,
annotations, annotations,
validations, validations,
}, },
@ -234,6 +237,7 @@ export default function NewAttributeSettings() {
required: formFields.isRequired ? formFields.required : undefined, required: formFields.isRequired ? formFields.required : undefined,
selector: formFields.selector, selector: formFields.selector,
permissions: formFields.permissions!, permissions: formFields.permissions!,
multivalued: formFields.multivalued,
annotations, annotations,
validations, validations,
}, },

View file

@ -11,11 +11,17 @@ import {
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { useState } from "react"; 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 { useTranslation } from "react-i18next";
import { HelpItem } from "ui-shared"; import { HelpItem } from "ui-shared";
import { adminClient } from "../../../admin-client"; import { adminClient } from "../../../admin-client";
import { DefaultSwitchControl } from "../../../components/SwitchControl";
import { FormAccess } from "../../../components/form/FormAccess"; import { FormAccess } from "../../../components/form/FormAccess";
import { KeycloakSpinner } from "../../../components/keycloak-spinner/KeycloakSpinner"; import { KeycloakSpinner } from "../../../components/keycloak-spinner/KeycloakSpinner";
import { KeycloakTextInput } from "../../../components/keycloak-text-input/KeycloakTextInput"; import { KeycloakTextInput } from "../../../components/keycloak-text-input/KeycloakTextInput";
@ -78,6 +84,7 @@ export const AttributeGeneralSettings = () => {
return ( return (
<FormAccess role="manage-realm" isHorizontal> <FormAccess role="manage-realm" isHorizontal>
<FormProvider {...form}>
<FormGroup <FormGroup
label={t("attributeName")} label={t("attributeName")}
labelIcon={ labelIcon={
@ -118,6 +125,11 @@ export const AttributeGeneralSettings = () => {
{...form.register("displayName")} {...form.register("displayName")}
/> />
</FormGroup> </FormGroup>
<DefaultSwitchControl
name="multivalued"
label={t("multivalued")}
labelIcon={t("multivaluedHelp")}
/>
<FormGroup <FormGroup
label={t("attributeGroup")} label={t("attributeGroup")}
labelIcon={ labelIcon={
@ -251,7 +263,10 @@ export const AttributeGeneralSettings = () => {
<FormGroup <FormGroup
label={t("required")} label={t("required")}
labelIcon={ labelIcon={
<HelpItem helpText={t("requiredHelp")} fieldLabelId="required" /> <HelpItem
helpText={t("requiredHelp")}
fieldLabelId="required"
/>
} }
fieldId="kc-required" fieldId="kc-required"
hasNoPaddingTop hasNoPaddingTop
@ -352,7 +367,9 @@ export const AttributeGeneralSettings = () => {
expandedText: t("hide"), expandedText: t("hide"),
collapsedText: t("showRemaining"), collapsedText: t("showRemaining"),
}} }}
onToggle={(isOpen) => setSelectRequiredForOpen(isOpen)} onToggle={(isOpen) =>
setSelectRequiredForOpen(isOpen)
}
selections={field.value} selections={field.value}
onSelect={(_, selectedValue) => { onSelect={(_, selectedValue) => {
const option = selectedValue.toString(); const option = selectedValue.toString();
@ -376,7 +393,10 @@ export const AttributeGeneralSettings = () => {
aria-labelledby={"scope"} aria-labelledby={"scope"}
> >
{clientScopes.map((option) => ( {clientScopes.map((option) => (
<SelectOption key={option.name} value={option.name} /> <SelectOption
key={option.name}
value={option.name}
/>
))} ))}
</Select> </Select>
)} )}
@ -387,6 +407,7 @@ export const AttributeGeneralSettings = () => {
)} )}
</> </>
)} )}
</FormProvider>
</FormAccess> </FormAccess>
); );
}; };

View file

@ -14,6 +14,7 @@ export interface UserProfileAttribute {
selector?: UserProfileAttributeSelector; selector?: UserProfileAttributeSelector;
displayName?: string; displayName?: string;
group?: string; group?: string;
multivalued?: boolean;
} }
export interface UserProfileAttributeRequired { export interface UserProfileAttributeRequired {
roles?: string[]; roles?: string[];
@ -41,6 +42,7 @@ export interface UserProfileAttributeMetadata {
group?: string; group?: string;
annotations?: Record<string, unknown>; annotations?: Record<string, unknown>;
validators?: Record<string, Record<string, unknown>>; validators?: Record<string, Record<string, unknown>>;
multivalued?: boolean;
} }
export interface UserProfileAttributeGroupMetadata { export interface UserProfileAttributeGroupMetadata {

View file

@ -4,13 +4,14 @@ import {
InputGroup, InputGroup,
TextInput, TextInput,
TextInputProps, TextInputProps,
TextInputTypes,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons"; import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
import { type TFunction } from "i18next"; import { type TFunction } from "i18next";
import { Fragment, useEffect, useMemo } from "react"; import { Fragment, useEffect, useMemo } from "react";
import { FieldPath, UseFormReturn, useWatch } from "react-hook-form"; import { FieldPath, UseFormReturn, useWatch } from "react-hook-form";
import { UserProfileFieldProps } from "./UserProfileFields"; import { InputType, UserProfileFieldProps } from "./UserProfileFields";
import { UserProfileGroup } from "./UserProfileGroup"; import { UserProfileGroup } from "./UserProfileGroup";
import { UserFormFields, fieldName, labelAttribute } from "./utils"; import { UserFormFields, fieldName, labelAttribute } from "./utils";
@ -19,6 +20,7 @@ export const MultiInputComponent = ({
form, form,
attribute, attribute,
renderer, renderer,
...rest
}: UserProfileFieldProps) => ( }: UserProfileFieldProps) => (
<UserProfileGroup t={t} form={form} attribute={attribute} renderer={renderer}> <UserProfileGroup t={t} form={form} attribute={attribute} renderer={renderer}>
<MultiLineInput <MultiLineInput
@ -29,6 +31,7 @@ export const MultiInputComponent = ({
addButtonLabel={t("addMultivaluedLabel", { addButtonLabel={t("addMultivaluedLabel", {
fieldLabel: labelAttribute(t, attribute), fieldLabel: labelAttribute(t, attribute),
})} })}
{...rest}
/> />
</UserProfileGroup> </UserProfileGroup>
); );
@ -40,11 +43,13 @@ export type MultiLineInputProps = Omit<TextInputProps, "form"> & {
addButtonLabel?: string; addButtonLabel?: string;
isDisabled?: boolean; isDisabled?: boolean;
defaultValue?: string[]; defaultValue?: string[];
inputType: InputType;
}; };
const MultiLineInput = ({ const MultiLineInput = ({
t, t,
name, name,
inputType,
form, form,
addButtonLabel, addButtonLabel,
isDisabled = false, isDisabled = false,
@ -84,6 +89,10 @@ const MultiLineInput = ({
}); });
}; };
const type = inputType.startsWith("html")
? (inputType.substring("html".length + 2) as TextInputTypes)
: "text";
useEffect(() => { useEffect(() => {
register(name); register(name);
}, [register]); }, [register]);
@ -99,6 +108,7 @@ const MultiLineInput = ({
name={`${name}.${index}.value`} name={`${name}.${index}.value`}
value={value} value={value}
isDisabled={isDisabled} isDisabled={isDisabled}
type={type}
{...rest} {...rest}
/> />
<Button <Button

View file

@ -182,11 +182,12 @@ const FormField = ({
const value = form.watch( const value = form.watch(
fieldName(attribute.name) as FieldPath<UserFormFields>, fieldName(attribute.name) as FieldPath<UserFormFields>,
); );
const inputType = useMemo( const inputType = useMemo(() => determineInputType(attribute), [attribute]);
() => determineInputType(attribute, value),
[attribute], const Component =
); attribute.multivalued || isMultiValue(value)
const Component = FIELDS[inputType]; ? FIELDS["multi-input"]
: FIELDS[inputType];
if (attribute.name === "locale") if (attribute.name === "locale")
return ( return (
@ -212,7 +213,6 @@ const DEFAULT_INPUT_TYPE = "text" satisfies InputType;
function determineInputType( function determineInputType(
attribute: UserProfileAttributeMetadata, attribute: UserProfileAttributeMetadata,
value: string | string[],
): InputType { ): InputType {
// Always treat the root attributes as a text field. // Always treat the root attributes as a text field.
if (isRootAttribute(attribute.name)) { if (isRootAttribute(attribute.name)) {
@ -226,11 +226,6 @@ function determineInputType(
return inputType; return inputType;
} }
// If the attribute has no valid input type and it's multi value use "multi-input"
if (isMultiValue(value)) {
return "multi-input";
}
// In all other cases use the default // In all other cases use the default
return DEFAULT_INPUT_TYPE; return DEFAULT_INPUT_TYPE;
} }

View file

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

View file

@ -51,6 +51,7 @@ public class AttributeMetadata {
private List<AttributeValidatorMetadata> validators; private List<AttributeValidatorMetadata> validators;
private Map<String, Object> annotations; private Map<String, Object> annotations;
private int guiOrder; private int guiOrder;
private boolean multivalued;
AttributeMetadata(String attributeName, int guiOrder) { AttributeMetadata(String attributeName, int guiOrder) {
@ -199,6 +200,14 @@ public class AttributeMetadata {
return this; return this;
} }
public void setMultivalued(boolean multivalued) {
this.multivalued = multivalued;
}
public boolean isMultivalued() {
return multivalued;
}
@Override @Override
public AttributeMetadata clone() { public AttributeMetadata clone() {
AttributeMetadata cloned = new AttributeMetadata(attributeName, guiOrder, selector, writeAllowed, required, readAllowed); AttributeMetadata cloned = new AttributeMetadata(attributeName, guiOrder, selector, writeAllowed, required, readAllowed);
@ -215,6 +224,7 @@ public class AttributeMetadata {
if (attributeGroupMetadata != null) { if (attributeGroupMetadata != null) {
cloned.setAttributeGroupMetadata(attributeGroupMetadata.clone()); cloned.setAttributeGroupMetadata(attributeGroupMetadata.clone());
} }
cloned.setMultivalued(multivalued);
return cloned; return cloned;
} }

View file

@ -31,6 +31,14 @@ public class ValidationResult {
*/ */
public static final ValidationResult OK = new ValidationResult(Collections.emptySet()); public static final ValidationResult OK = new ValidationResult(Collections.emptySet());
public static ValidationResult of(ValidationError... errors) {
return new ValidationResult(Set.of(errors));
}
public static ValidationResult of(Set<ValidationError> errors) {
return new ValidationResult(errors);
}
/** /**
* Holds the {@link ValidationError ValidationError's} that occurred during validation. * Holds the {@link ValidationError ValidationError's} that occurred during validation.
*/ */

View file

@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -35,6 +36,10 @@ public class ValidatorConfig {
*/ */
public static final ValidatorConfig EMPTY = new ValidatorConfig(Collections.emptyMap()); public static final ValidatorConfig EMPTY = new ValidatorConfig(Collections.emptyMap());
public static boolean isEmpty(ValidatorConfig config) {
return EMPTY.equals(Optional.ofNullable(config).orElse(EMPTY));
}
/** /**
* Holds the backing map for the {@link Validator} config. * Holds the backing map for the {@link Validator} config.
*/ */

View file

@ -217,7 +217,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
attributes.put("properties", theme.getProperties()); attributes.put("properties", theme.getProperties());
attributes.put("realmName", getRealmName()); attributes.put("realmName", getRealmName());
attributes.put("user", new ProfileBean(user)); attributes.put("user", new ProfileBean(user, session));
KeycloakUriInfo uriInfo = session.getContext().getUri(); KeycloakUriInfo uriInfo = session.getContext().getUri();
attributes.put("url", new UrlBean(realm, theme, uriInfo.getBaseUri(), null)); attributes.put("url", new UrlBean(realm, theme, uriInfo.getBaseUri(), null));

View file

@ -17,7 +17,11 @@
package org.keycloak.email.freemarker.beans; package org.keycloak.email.freemarker.beans;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.userprofile.UserProfileProvider;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -34,17 +38,25 @@ public class ProfileBean {
private UserModel user; private UserModel user;
private final Map<String, String> attributes = new HashMap<>(); private final Map<String, String> attributes = new HashMap<>();
public ProfileBean(UserModel user) { public ProfileBean(UserModel user, KeycloakSession session) {
this.user = user; this.user = user;
if (user.getAttributes() != null) { if (user.getAttributes() != null) {
//TODO: there is no need to set only a single value for attributes but changing this might break existing
// deployments using email templates, if we change the contract to return multiple values for attributes
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UPConfig configuration = provider.getConfiguration();
for (Map.Entry<String, List<String>> attr : user.getAttributes().entrySet()) { for (Map.Entry<String, List<String>> attr : user.getAttributes().entrySet()) {
List<String> attrValue = attr.getValue(); List<String> attrValue = attr.getValue();
if (attrValue != null && attrValue.size() > 0) { if (attrValue != null && attrValue.size() > 0) {
attributes.put(attr.getKey(), attrValue.get(0)); attributes.put(attr.getKey(), attrValue.get(0));
} }
if (attrValue != null && attrValue.size() > 1) { UPAttribute attribute = configuration.getAttribute(attr.getKey());
boolean multivalued = attribute != null && attribute.isMultivalued();
if (!multivalued && attrValue != null && attrValue.size() > 1) {
logger.warnf("There are more values for attribute '%s' of user '%s' . Will display just first value", attr.getKey(), user.getUsername()); logger.warnf("There are more values for attribute '%s' of user '%s' . Will display just first value", attr.getKey(), user.getUsername());
} }
} }

View file

@ -1,9 +1,13 @@
package org.keycloak.forms.login.freemarker.model; package org.keycloak.forms.login.freemarker.model;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -11,6 +15,7 @@ import java.util.stream.Stream;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.userprofile.AttributeGroupMetadata;
import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.AttributeValidatorMetadata; import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.Attributes;
@ -24,6 +29,22 @@ import org.keycloak.userprofile.UserProfileProvider;
*/ */
public abstract class AbstractUserProfileBean { public abstract class AbstractUserProfileBean {
private static final Comparator<Attribute> ATTRIBUTE_COMPARATOR = (a1, a2) -> {
AttributeGroup g1 = a1.getGroup();
AttributeGroup g2 = a2.getGroup();
if (g1 == null && g2 == null) {
return a1.compareTo(a2);
}
if (g1 != null && g1.equals(g2)) {
return a1.compareTo(a2);
}
return Comparator.nullsLast(AttributeGroup::compareTo).compare(g1, g2);
};
protected final MultivaluedMap<String, String> formData; protected final MultivaluedMap<String, String> formData;
protected UserProfile profile; protected UserProfile profile;
protected List<Attribute> attributes; protected List<Attribute> attributes;
@ -104,7 +125,7 @@ public abstract class AbstractUserProfileBean {
.filter((am) -> writeableOnly ? !profileAttributes.isReadOnly(am.getName()) : true) .filter((am) -> writeableOnly ? !profileAttributes.isReadOnly(am.getName()) : true)
.filter((am) -> !profileAttributes.getUnmanagedAttributes().containsKey(am.getName())) .filter((am) -> !profileAttributes.getUnmanagedAttributes().containsKey(am.getName()))
.map(Attribute::new) .map(Attribute::new)
.sorted() .sorted(ATTRIBUTE_COMPARATOR)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@ -127,6 +148,10 @@ public abstract class AbstractUserProfileBean {
return metadata.getAttributeDisplayName(); return metadata.getAttributeDisplayName();
} }
public boolean isMultivalued() {
return metadata.isMultivalued();
}
public String getValue() { public String getValue() {
List<String> v = getValues(); List<String> v = getValues();
if (v == null || v.isEmpty()) { if (v == null || v.isEmpty()) {
@ -177,8 +202,16 @@ public abstract class AbstractUserProfileBean {
} }
public Map<String, Object> getHtml5DataAnnotations() { public Map<String, Object> getHtml5DataAnnotations() {
return getAnnotations().entrySet().stream() Map<String, Object> groupAnnotations = Optional.ofNullable(getGroup()).map(AttributeGroup::getAnnotations).orElse(Map.of());
.filter((entry) -> entry.getKey().startsWith("kc")).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); Map<String, Object> annotations = Stream.concat(getAnnotations().entrySet().stream(), groupAnnotations.entrySet().stream())
.filter((entry) -> entry.getKey().startsWith("kc")).collect(Collectors.toMap(Entry::getKey, Entry::getValue));
if (isMultivalued()) {
annotations = new HashMap<>(annotations);
annotations.put("kcMultivalued", "");
}
return annotations;
} }
/** /**
@ -194,39 +227,72 @@ public abstract class AbstractUserProfileBean {
return metadata.getValidators().stream().collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig)); return metadata.getValidators().stream().collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
} }
public String getGroup() { public AttributeGroup getGroup() {
if (metadata.getAttributeGroupMetadata() != null) { AttributeGroupMetadata groupMetadata = metadata.getAttributeGroupMetadata();
return metadata.getAttributeGroupMetadata().getName();
if (groupMetadata != null) {
return new AttributeGroup(groupMetadata);
} }
return null; return null;
} }
public String getGroupDisplayHeader() {
if (metadata.getAttributeGroupMetadata() != null) {
return metadata.getAttributeGroupMetadata().getDisplayHeader();
}
return null;
}
public String getGroupDisplayDescription() {
if (metadata.getAttributeGroupMetadata() != null) {
return metadata.getAttributeGroupMetadata().getDisplayDescription();
}
return null;
}
public Map<String, Object> getGroupAnnotations() {
if ((metadata.getAttributeGroupMetadata() == null) || (metadata.getAttributeGroupMetadata().getAnnotations() == null)) {
return Collections.emptyMap();
}
return metadata.getAttributeGroupMetadata().getAnnotations();
}
@Override @Override
public int compareTo(Attribute o) { public int compareTo(Attribute o) {
return Integer.compare(metadata.getGuiOrder(), o.metadata.getGuiOrder()); return Integer.compare(metadata.getGuiOrder(), o.metadata.getGuiOrder());
} }
} }
public class AttributeGroup implements Comparable<AttributeGroup> {
private AttributeGroupMetadata metadata;
AttributeGroup(AttributeGroupMetadata metadata) {
this.metadata = metadata;
}
public String getName() {
return metadata.getName();
}
public String getDisplayHeader() {
return Optional.ofNullable(metadata.getDisplayHeader()).orElse(getName());
}
public String getDisplayDescription() {
return metadata.getDisplayDescription();
}
public Map<String, Object> getAnnotations() {
return Optional.ofNullable(metadata.getAnnotations()).orElse(Map.of());
}
public Map<String, Object> getHtml5DataAnnotations() {
return getAnnotations().entrySet().stream()
.filter((entry) -> entry.getKey().startsWith("kc")).collect(Collectors.toMap(Entry::getKey, Entry::getValue));
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AttributeGroup that = (AttributeGroup) o;
return Objects.equals(metadata.getName(), that.metadata.getName());
}
@Override
public int hashCode() {
return Objects.hash(metadata);
}
@Override
public String toString() {
return metadata.getName();
}
@Override
public int compareTo(AttributeGroup o) {
return getDisplayHeader().compareTo(o.getDisplayHeader());
}
}
} }

View file

@ -52,6 +52,7 @@ import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.representations.userprofile.config.UPGroup; import org.keycloak.representations.userprofile.config.UPGroup;
import org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator; import org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator;
import org.keycloak.userprofile.validator.ImmutableAttributeValidator; import org.keycloak.userprofile.validator.ImmutableAttributeValidator;
import org.keycloak.userprofile.validator.MultiValueValidator;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.validate.AbstractSimpleValidator; import org.keycloak.validate.AbstractSimpleValidator;
import org.keycloak.validate.ValidatorConfig; import org.keycloak.validate.ValidatorConfig;
@ -341,6 +342,13 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
validators.add(new AttributeValidatorMetadata(ImmutableAttributeValidator.ID)); validators.add(new AttributeValidatorMetadata(ImmutableAttributeValidator.ID));
// make sure managed attributes single-valued are constrained to a single value
if (!attrConfig.isMultivalued() && validators.stream().map(AttributeValidatorMetadata::getValidatorId).noneMatch(MultiValueValidator.ID::equals)) {
validators.add(new AttributeValidatorMetadata(MultiValueValidator.ID, ValidatorConfig.builder()
.config("max", "1")
.build()));
}
if (isBuiltInAttribute(attributeName)) { if (isBuiltInAttribute(attributeName)) {
// make sure username and email are writable if permissions are not set // make sure username and email are writable if permissions are not set
if (permissions == null || permissions.isEmpty()) { if (permissions == null || permissions.isEmpty()) {
@ -391,13 +399,15 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
.addReadCondition(readAllowed) .addReadCondition(readAllowed)
.addWriteCondition(writeAllowed) .addWriteCondition(writeAllowed)
.addValidators(validators) .addValidators(validators)
.setRequired(required); .setRequired(required)
.setMultivalued(attrConfig.isMultivalued());
} }
} else { } else {
decoratedMetadata.addAttribute(attributeName, guiOrder, validators, selector, writeAllowed, required, readAllowed) decoratedMetadata.addAttribute(attributeName, guiOrder, validators, selector, writeAllowed, required, readAllowed)
.addAnnotations(annotations) .addAnnotations(annotations)
.setAttributeDisplayName(attrConfig.getDisplayName()) .setAttributeDisplayName(attrConfig.getDisplayName())
.setAttributeGroupMetadata(groupMetadata); .setAttributeGroupMetadata(groupMetadata)
.setMultivalued(attrConfig.isMultivalued());
} }
} }

View file

@ -0,0 +1,139 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.userprofile.validator;
import static org.keycloak.validate.validators.ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_NUMBER_VALUE;
import static org.keycloak.validate.validators.ValidatorConfigValidator.MESSAGE_CONFIG_MISSING_VALUE;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.userprofile.AttributeContext;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.UserProfileAttributeValidationContext;
import org.keycloak.utils.StringUtil;
import org.keycloak.validate.SimpleValidator;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidationResult;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.validators.ValidatorConfigValidator;
public class MultiValueValidator implements SimpleValidator, ConfiguredProvider {
public static final String ID = "multivalued";
public static final String MESSAGE_INVALID_SIZE = "error-invalid-multivalued-size";
public static final String KEY_MIN = "min";
public static final String KEY_MAX = "max";
@Override
public String getId() {
return ID;
}
@Override
public ValidationContext validate(Object value, String inputHint, ValidationContext context, ValidatorConfig config) {
AttributeContext attributeContext = UserProfileAttributeValidationContext.from(context).getAttributeContext();
Long min = Optional.ofNullable(config.getLong(KEY_MIN)).orElse(getDefaultMinSize(context));
Long max = config.getLong(KEY_MAX);
if (!(value instanceof Collection)) {
addError(inputHint, context, min, max);
return context;
}
long length = ((Collection<String>) value).stream().filter(StringUtil::isNotBlank).count();
if (length == 0 && attributeContext.getMetadata().isRequired(attributeContext)) {
// if no value is set and attribute is required, do not validate in favor of the required validator
return context;
}
if (!(length >= min && length <= max)) {
addError(inputHint, context, min, max);
return context;
}
return context;
}
@Override
public ValidationResult validateConfig(KeycloakSession session, ValidatorConfig config) {
if (ValidatorConfig.isEmpty(config)) {
return ValidationResult.of(new ValidationError(ID, KEY_MAX, MESSAGE_CONFIG_MISSING_VALUE));
}
Set<ValidationError> errors = new HashSet<>();
Long min = config.getLong(KEY_MIN);
Long max = config.getLong(KEY_MAX);
if (min == null && config.containsKey(KEY_MIN)) {
errors.add(new ValidationError(ID, KEY_MIN, MESSAGE_CONFIG_INVALID_NUMBER_VALUE));
}
if (max == null) {
errors.add(new ValidationError(ID, KEY_MAX,
config.containsKey(KEY_MAX) ? MESSAGE_CONFIG_INVALID_NUMBER_VALUE : MESSAGE_CONFIG_MISSING_VALUE));
} else if (min != null && (min > max)) {
errors.add(new ValidationError(ID, KEY_MAX, ValidatorConfigValidator.MESSAGE_CONFIG_INVALID_VALUE));
}
return ValidationResult.of(errors);
}
@Override
public String getHelpText() {
return "Multivalued validator";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> properties = new ArrayList<>();
ProviderConfigProperty property;
property = new ProviderConfigProperty();
property.setName(KEY_MIN);
property.setLabel("Minimum size");
property.setHelpText("The minimum size");
property.setType(ProviderConfigProperty.STRING_TYPE);
properties.add(property);
property = new ProviderConfigProperty();
property.setName(KEY_MAX);
property.setLabel("Maximum size");
property.setHelpText("The maximum size");
property.setType(ProviderConfigProperty.STRING_TYPE);
properties.add(property);
return properties;
}
private long getDefaultMinSize(ValidationContext context) {
AttributeContext attributeContext = UserProfileAttributeValidationContext.from(context).getAttributeContext();
AttributeMetadata metadata = attributeContext.getMetadata();
return metadata.isRequired(attributeContext) ? 1 : 0;
}
private void addError(String inputHint, ValidationContext context, Long min, Long max) {
context.addError(new ValidationError(ID, inputHint, MESSAGE_INVALID_SIZE, min, max));
}
}

View file

@ -14,3 +14,4 @@ org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueValidator
org.keycloak.userprofile.validator.ImmutableAttributeValidator org.keycloak.userprofile.validator.ImmutableAttributeValidator
org.keycloak.userprofile.validator.UsernameProhibitedCharactersValidator org.keycloak.userprofile.validator.UsernameProhibitedCharactersValidator
org.keycloak.userprofile.validator.PersonNameProhibitedCharactersValidator org.keycloak.userprofile.validator.PersonNameProhibitedCharactersValidator
org.keycloak.userprofile.validator.MultiValueValidator

View file

@ -25,8 +25,10 @@ import java.util.Map;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.keycloak.testsuite.util.UIUtils; import org.keycloak.testsuite.util.UIUtils;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.By; import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.FindBy;
@ -119,10 +121,12 @@ public class LoginUpdateProfilePage extends AbstractPage {
return driver.findElement(By.cssSelector("label[for="+fieldId+"]")).getText(); return driver.findElement(By.cssSelector("label[for="+fieldId+"]")).getText();
} }
public WebElement getFieldById(String fieldId) { public WebElement getElementById(String fieldId) {
try { try {
return driver.findElement(By.id(fieldId)); By id = By.id(fieldId);
} catch (NoSuchElementException nsee) { WaitUtils.waitUntilElement(id);
return driver.findElement(id);
} catch (NoSuchElementException | TimeoutException ignore) {
return null; return null;
} }
} }
@ -149,6 +153,41 @@ public class LoginUpdateProfilePage extends AbstractPage {
} }
} }
public void setAttribute(String elementId, String value) {
WebElement element = getElementById(elementId);
if (element != null) {
element.clear();
element.sendKeys(value);
}
}
public void clickAddAttributeValue(String elementId) {
WebElement element = getElementById("kc-add-" + elementId);
if (element != null) {
element.click();
}
}
public void clickRemoveAttributeValue(String elementId) {
WebElement element = getElementById("kc-remove-" + elementId);
if (element != null) {
element.click();
}
}
public String getAttribute(String elementId) {
WebElement element = getElementById(elementId);
if (element != null) {
return element.getAttribute("value");
}
return null;
}
public static class Update { public static class Update {
private final LoginUpdateProfilePage page; private final LoginUpdateProfilePage page;
private String firstName; private String firstName;

View file

@ -416,7 +416,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}}," + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"attr1\"," + PERMISSIONS_ALL + "}," + "{\"name\": \"attr1\"," + PERMISSIONS_ALL + "},"
+ "{\"name\": \"attr2\"," + PERMISSIONS_ALL + "}" + "{\"name\": \"attr2\"," + PERMISSIONS_ALL + ", \"multivalued\": true}"
+ "]}"; + "]}";
setUserProfileConfiguration(userProfileCfg); setUserProfileConfiguration(userProfileCfg);

View file

@ -19,10 +19,18 @@ package org.keycloak.testsuite.actions;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN;
import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert; import org.junit.Assert;
@ -37,10 +45,13 @@ import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.AppPage.RequestType; import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.ErrorPage;
@ -48,6 +59,7 @@ import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginUpdateProfileEditUsernameAllowedPage; import org.keycloak.testsuite.pages.LoginUpdateProfileEditUsernameAllowedPage;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileContext;
import org.openqa.selenium.htmlunit.HtmlUnitDriver;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -384,4 +396,104 @@ public class RequiredActionUpdateProfileTest extends AbstractTestRealmKeycloakTe
} }
} }
@Test
@IgnoreBrowserDriver(HtmlUnitDriver.class) // we can't yet run modern JavaScript using HtmlUnit
public void testMultivaluedAttributes() {
UserProfileResource userProfile = testRealm().users().userProfile();
UPConfig configuration = userProfile.getConfiguration();
try {
UPConfig testUpConfig = configuration.clone();
List<String> attributes = List.of("foo", "bar", "zar");
List<String> values = IntStream.range(0, 5).mapToObj(Integer::toString).collect(Collectors.toList());
for (String attribute : attributes) {
testUpConfig.addOrReplaceAttribute(
new UPAttribute(attribute, true, new UPAttributePermissions(Set.of(), Set.of(ROLE_USER, ROLE_ADMIN)))
);
}
userProfile.update(testUpConfig);
loginPage.open();
loginPage.login("john-doh@localhost", "password");
updateProfilePage.assertCurrent();
for (String attribute : attributes) {
updateProfilePage.assertCurrent();
// add multiple values
for (String value : values) {
String elementId = attribute + "-" + value;
updateProfilePage.setAttribute(elementId, value);
updateProfilePage.clickAddAttributeValue(elementId);
}
updateProfilePage.update("f", "l", "e@keycloak.org");
UserRepresentation userRep = ActionUtil.findUserWithAdminClient(adminClient, "john-doh@localhost");
assertThat(userRep.getAttributes().get(attribute), Matchers.containsInAnyOrder(values.toArray()));
// make sure multiple values are properly rendered
userRep.setRequiredActions(List.of(UserModel.RequiredAction.UPDATE_PROFILE.name()));
testRealm().users().get(userRep.getId()).update(userRep);
loginPage.open();
for (String value : values) {
assertEquals(value, updateProfilePage.getAttribute(attribute + "-" + value));
}
// remove multiple values, only the last value should be kept as you can't remove the last one
for (String value : values) {
updateProfilePage.clickRemoveAttributeValue(attribute + "-0");
}
updateProfilePage.update("f", "l", "e@keycloak.org");
userRep = ActionUtil.findUserWithAdminClient(adminClient, "john-doh@localhost");
assertThat(userRep.getAttributes().get(attribute), Matchers.containsInAnyOrder(values.get(values.size() - 1)));
// make sure adding/removing within the same context works
userRep.setRequiredActions(List.of(UserModel.RequiredAction.UPDATE_PROFILE.name()));
testRealm().users().get(userRep.getId()).update(userRep);
loginPage.open();
for (String value : values) {
String elementId = attribute + "-" + value;
updateProfilePage.setAttribute(elementId, value);
updateProfilePage.clickAddAttributeValue(elementId);
}
for (String value : values) {
assertEquals(value, updateProfilePage.getAttribute(attribute + "-" + value));
}
for (String value : values) {
updateProfilePage.clickRemoveAttributeValue(attribute + "-0");
}
updateProfilePage.update("f", "l", "e@keycloak.org");
userRep = ActionUtil.findUserWithAdminClient(adminClient, "john-doh@localhost");
assertThat(userRep.getAttributes().get(attribute), Matchers.containsInAnyOrder(values.get(values.size() - 1)));
// at the end the attribute is set with multiple values
userRep.setRequiredActions(List.of(UserModel.RequiredAction.UPDATE_PROFILE.name()));
testRealm().users().get(userRep.getId()).update(userRep);
loginPage.open();
for (String value : values) {
String elementId = attribute + "-" + value;
updateProfilePage.setAttribute(elementId, value);
updateProfilePage.clickAddAttributeValue(elementId);
}
updateProfilePage.update("f", "l", "e@keycloak.org");
// restart the update profile flow
userRep = ActionUtil.findUserWithAdminClient(adminClient, "john-doh@localhost");
userRep.setRequiredActions(List.of(UserModel.RequiredAction.UPDATE_PROFILE.name()));
testRealm().users().get(userRep.getId()).update(userRep);
loginPage.open();
}
UserRepresentation userRep = ActionUtil.findUserWithAdminClient(adminClient, "john-doh@localhost");
// all attributes should be set with multiple values
for (String attribute : attributes) {
assertThat(userRep.getAttributes().get(attribute), Matchers.containsInAnyOrder(values.toArray()));
}
} finally {
userProfile.update(configuration);
}
}
} }

View file

@ -174,45 +174,45 @@ public class RequiredActionUpdateProfileWithUserProfileTest extends AbstractTest
updateProfilePage.assertCurrent(); updateProfilePage.assertCurrent();
String htmlFormId="kc-update-profile-form"; String htmlFormId="kc-update-profile-form";
//assert fields and groups location in form //assert fields and groups location in form, attributes without a group are the last
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(1) > label#header-company")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > label#description-company")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(2) > input#firstName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#department")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(1) > label#header-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(1) > label#header-contact")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > label#description-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > input#email")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#department") By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#lastName")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-contact") By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(2) > input#username")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#email") By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#firstName")
).isDisplayed() ).isDisplayed()
); );
} }

View file

@ -3714,6 +3714,7 @@ public class UserTest extends AbstractAdminTest {
private UPAttribute createAttributeMetadata(String name) { private UPAttribute createAttributeMetadata(String name) {
UPAttribute attribute = new UPAttribute(); UPAttribute attribute = new UPAttribute();
attribute.setName(name); attribute.setName(name);
attribute.setMultivalued(true);
UPAttributePermissions permissions = new UPAttributePermissions(); UPAttributePermissions permissions = new UPAttributePermissions();
permissions.setEdit(Set.of("user", "admin")); permissions.setEdit(Set.of("user", "admin"));
attribute.setPermissions(permissions); attribute.setPermissions(permissions);

View file

@ -407,45 +407,45 @@ public class KcOidcFirstBrokerLoginTest extends AbstractFirstBrokerLoginTest {
//assert fields location in form //assert fields location in form
String htmlFormId = "kc-idp-review-profile-form"; String htmlFormId = "kc-idp-review-profile-form";
//assert fields and groups location in form //assert fields and groups location in form, attributes without a group are the last
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(1) > label#header-company")
).isDisplayed() ).isDisplayed()
); );
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > label#description-company")
).isDisplayed() ).isDisplayed()
); );
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(2) > input#firstName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#department")
).isDisplayed() ).isDisplayed()
); );
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(1) > label#header-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(1) > label#header-contact")
).isDisplayed() ).isDisplayed()
); );
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > label#description-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > input#email")
).isDisplayed() ).isDisplayed()
); );
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#department") By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#lastName")
).isDisplayed() ).isDisplayed()
); );
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-contact") By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(2) > input#username")
).isDisplayed() ).isDisplayed()
); );
org.junit.Assert.assertTrue( org.junit.Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#email") By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#firstName")
).isDisplayed() ).isDisplayed()
); );
} }

View file

@ -107,9 +107,9 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
updateProfilePage.assertCurrent(); updateProfilePage.assertCurrent();
Assert.assertEquals("John", updateProfilePage.getFirstName()); Assert.assertEquals("John", updateProfilePage.getFirstName());
Assert.assertEquals("Doe", updateProfilePage.getLastName()); Assert.assertEquals("Doe", updateProfilePage.getLastName());
Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("firstName").isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("lastName").isEnabled());
Assert.assertNull(updateProfilePage.getFieldById("postal_code")); Assert.assertNull(updateProfilePage.getElementById("postal_code"));
updateProfilePage.prepareUpdate().submit(); updateProfilePage.prepareUpdate().submit();
} }
@ -145,10 +145,10 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
Assert.assertEquals("John", updateProfilePage.getFirstName()); Assert.assertEquals("John", updateProfilePage.getFirstName());
Assert.assertEquals("Doe", updateProfilePage.getLastName()); Assert.assertEquals("Doe", updateProfilePage.getLastName());
Assert.assertEquals("1234", updateProfilePage.getFieldById("postal_code").getAttribute("value")); Assert.assertEquals("1234", updateProfilePage.getElementById("postal_code").getAttribute("value"));
Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("firstName").isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("lastName").isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById("postal_code").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("postal_code").isEnabled());
updateProfilePage.prepareUpdate().submit(); updateProfilePage.prepareUpdate().submit();
} finally { } finally {
testRealm().users().userProfile().update(origConfig); testRealm().users().userProfile().update(origConfig);
@ -175,9 +175,9 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
updateProfilePage.assertCurrent(); updateProfilePage.assertCurrent();
Assert.assertEquals("John", updateProfilePage.getFirstName()); Assert.assertEquals("John", updateProfilePage.getFirstName());
Assert.assertEquals("Doe", updateProfilePage.getLastName()); Assert.assertEquals("Doe", updateProfilePage.getLastName());
Assert.assertFalse(updateProfilePage.getFieldById("firstName").isEnabled()); Assert.assertFalse(updateProfilePage.getElementById("firstName").isEnabled());
Assert.assertFalse(updateProfilePage.getFieldById("lastName").isEnabled()); Assert.assertFalse(updateProfilePage.getElementById("lastName").isEnabled());
Assert.assertNull(updateProfilePage.getFieldById("postal_code")); Assert.assertNull(updateProfilePage.getElementById("postal_code"));
updateProfilePage.prepareUpdate().submit(); updateProfilePage.prepareUpdate().submit();
} finally { } finally {
setLDAPWritable(); setLDAPWritable();
@ -206,9 +206,9 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
updateProfilePage.assertCurrent(); updateProfilePage.assertCurrent();
Assert.assertEquals("Mary", updateProfilePage.getFirstName()); Assert.assertEquals("Mary", updateProfilePage.getFirstName());
Assert.assertEquals("Kelly", updateProfilePage.getLastName()); Assert.assertEquals("Kelly", updateProfilePage.getLastName());
Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("firstName").isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("lastName").isEnabled());
Assert.assertNull(updateProfilePage.getFieldById("postal_code")); Assert.assertNull(updateProfilePage.getElementById("postal_code"));
updateProfilePage.prepareUpdate().submit(); updateProfilePage.prepareUpdate().submit();
} finally { } finally {
setLDAPWritable(); setLDAPWritable();

View file

@ -442,55 +442,46 @@ public class RegisterWithUserProfileTest extends AbstractTestRealmKeycloakTest {
registerPage.assertCurrent(); registerPage.assertCurrent();
String htmlFormId="kc-register-form"; String htmlFormId="kc-register-form";
//assert fields and groups location in form //assert fields and groups location in form, attributes without a group are the last
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(1) > label#header-company")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > label#description-company")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("#password") By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#department")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("#password-confirm") By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(1) > label#header-contact")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#firstName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > input#email")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#lastName")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(2) > label#description-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(2) > input#username")
).isDisplayed() ).isDisplayed()
); );
// firstname order is after username, so it will render after password and password confirmation fields
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#department") By.cssSelector("form#"+htmlFormId+" > div:nth-child(9) > div:nth-child(2) > input#firstName")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(8) > div:nth-child(1) > label#header-contact")
).isDisplayed()
);
Assert.assertTrue(
driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(9) > div:nth-child(2) > input#email")
).isDisplayed() ).isDisplayed()
); );
} }

View file

@ -220,45 +220,45 @@ public class VerifyProfileTest extends AbstractTestRealmKeycloakTest {
verifyProfilePage.assertCurrent(); verifyProfilePage.assertCurrent();
String htmlFormId="kc-update-profile-form"; String htmlFormId="kc-update-profile-form";
//assert fields and groups location in form //assert fields and groups location in form, attributes without a group are the last
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > input#lastName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(1) > label#header-company")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#username") By.cssSelector("form#"+htmlFormId+" > div:nth-child(1) > div:nth-child(2) > label#description-company")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(2) > input#firstName") By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#department")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(1) > label#header-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(1) > label#header-contact")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > label#description-company") By.cssSelector("form#"+htmlFormId+" > div:nth-child(4) > div:nth-child(2) > input#email")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#department") By.cssSelector("form#"+htmlFormId+" > div:nth-child(5) > div:nth-child(2) > input#lastName")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(1) > label#header-contact") By.cssSelector("form#"+htmlFormId+" > div:nth-child(6) > div:nth-child(2) > input#username")
).isDisplayed() ).isDisplayed()
); );
Assert.assertTrue( Assert.assertTrue(
driver.findElement( driver.findElement(
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#email") By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#firstName")
).isDisplayed() ).isDisplayed()
); );
} }

View file

@ -42,6 +42,7 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import org.hamcrest.Matchers;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
@ -79,6 +80,7 @@ import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.config.UPConfigUtils; import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.userprofile.validator.MultiValueValidator;
import org.keycloak.userprofile.validator.PersonNameProhibitedCharactersValidator; import org.keycloak.userprofile.validator.PersonNameProhibitedCharactersValidator;
import org.keycloak.userprofile.validator.UsernameIDNHomographValidator; import org.keycloak.userprofile.validator.UsernameIDNHomographValidator;
import org.keycloak.validate.ValidationError; import org.keycloak.validate.ValidationError;
@ -2169,4 +2171,77 @@ public class UserProfileTest extends AbstractUserProfileTest {
assertTrue(ve.hasError(LengthValidator.MESSAGE_INVALID_LENGTH)); assertTrue(ve.hasError(LengthValidator.MESSAGE_INVALID_LENGTH));
} }
} }
@Test
public void testMultivalued() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testMultivalued);
}
private static void testMultivalued(KeycloakSession session) {
UserProfileProvider provider = getUserProfileProvider(session);
UPConfig upConfig = UPConfigUtils.parseSystemDefaultConfig();
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));
attributes.put(UserModel.EMAIL, List.of(userName + "@keycloak.org"));
attributes.put(UserModel.FIRST_NAME, List.of("Joe"));
attributes.put(UserModel.LAST_NAME, List.of("Doe"));
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
UserModel user = profile.create();
profile = provider.create(UserProfileContext.USER_API, user);
containsInAnyOrder(profile.getAttributes().nameSet(), UserModel.EMAIL);
UPAttribute foo = new UPAttribute("foo", new UPAttributePermissions(Set.of(), Set.of(UserProfileConstants.ROLE_ADMIN)));
upConfig.addOrReplaceAttribute(foo);
provider.setConfiguration(upConfig);
List<String> expectedValues = List.of("a", "b");
attributes.put("foo", expectedValues);
profile = provider.create(UserProfileContext.USER_API, attributes, user);
try {
profile.update();
fail("Should fail because foo attribute is single-valued by default");
} catch (ValidationException ve) {
assertTrue(ve.hasError(MultiValueValidator.MESSAGE_INVALID_SIZE));
}
foo.setMultivalued(true);
upConfig.addOrReplaceAttribute(foo);
provider.setConfiguration(upConfig);
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update();
List<String> actualValues = user.getAttributes().get("foo");
assertThat(actualValues, Matchers.containsInAnyOrder(expectedValues.toArray()));
attributes.put("foo", List.of("a", "b", "c"));
foo.addValidation(MultiValueValidator.ID, Map.of(MultiValueValidator.KEY_MAX, 2));
provider.setConfiguration(upConfig);
profile = provider.create(UserProfileContext.USER_API, attributes, user);
try {
profile.update();
fail("Should fail because foo attribute expects 2 values");
} catch (ValidationException ve) {
assertTrue(ve.hasError(MultiValueValidator.MESSAGE_INVALID_SIZE));
}
attributes.put("foo", List.of("a"));
foo.addValidation(MultiValueValidator.ID, Map.of(MultiValueValidator.KEY_MIN, 2, MultiValueValidator.KEY_MAX, 2));
provider.setConfiguration(upConfig);
profile = provider.create(UserProfileContext.USER_API, attributes, user);
try {
profile.update();
fail("Should fail because foo attribute expects at least 2 values");
} catch (ValidationException ve) {
assertTrue(ve.hasError(MultiValueValidator.MESSAGE_INVALID_SIZE));
}
attributes.put("foo", List.of("a", "b"));
foo.addValidation(MultiValueValidator.ID, Map.of(MultiValueValidator.KEY_MIN, 2, MultiValueValidator.KEY_MAX, 2));
provider.setConfiguration(upConfig);
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update();
}
} }

View file

@ -35,6 +35,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.ModelTest; import org.keycloak.testsuite.arquillian.annotation.ModelTest;
import org.keycloak.userprofile.validator.MultiValueValidator;
import org.keycloak.validate.AbstractSimpleValidator; import org.keycloak.validate.AbstractSimpleValidator;
import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError; import org.keycloak.validate.ValidationError;
@ -577,4 +578,42 @@ public class BuiltinValidatorsTest extends AbstractKeycloakTest {
Assert.assertTrue(result.isValid()); Assert.assertTrue(result.isValid());
} }
@Test
@ModelTest
public void testMultivaluedValidatorConfiguration(KeycloakSession session) {
// invalid min and max config values
ValidatorConfig config = new ValidatorConfig(ImmutableMap.of(MultiValueValidator.KEY_MIN, new Object(), MultiValueValidator.KEY_MAX, "invalid"));
ValidationResult result = BuiltinValidators.validatorConfigValidator().validate(config, MultiValueValidator.ID, new ValidationContext(session)).toResult();
Assert.assertFalse(result.isValid());
ValidationError[] errors = result.getErrors().toArray(new ValidationError[0]);
ValidationError error0 = errors[0];
Assert.assertNotNull(error0);
Assert.assertEquals(MultiValueValidator.ID, error0.getValidatorId());
Assert.assertEquals(MultiValueValidator.KEY_MAX, error0.getInputHint());
ValidationError error1 = errors[1];
Assert.assertNotNull(error1);
Assert.assertEquals(MultiValueValidator.ID, error1.getValidatorId());
Assert.assertEquals(MultiValueValidator.KEY_MIN, error1.getInputHint());
// empty config
result = BuiltinValidators.validatorConfigValidator().validate(null, MultiValueValidator.ID, new ValidationContext(session)).toResult();
Assert.assertEquals(1, result.getErrors().size());
result = BuiltinValidators.validatorConfigValidator().validate(ValidatorConfig.EMPTY, MultiValueValidator.ID, new ValidationContext(session)).toResult();
Assert.assertEquals(1, result.getErrors().size());
// correct config
Assert.assertTrue(BuiltinValidators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(MultiValueValidator.KEY_MAX, "10")), MultiValueValidator.ID, new ValidationContext(session)).toResult().isValid());
Assert.assertTrue(BuiltinValidators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(MultiValueValidator.KEY_MIN, "10", MultiValueValidator.KEY_MAX, "10")), MultiValueValidator.ID, new ValidationContext(session)).toResult().isValid());
Assert.assertTrue(BuiltinValidators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(MultiValueValidator.KEY_MIN, "10", MultiValueValidator.KEY_MAX, "11")), MultiValueValidator.ID, new ValidationContext(session)).toResult().isValid());
// max is smaller than min
Assert.assertFalse(BuiltinValidators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(MultiValueValidator.KEY_MIN, "10", MultiValueValidator.KEY_MAX, "9")), MultiValueValidator.ID, new ValidationContext(session)).toResult().isValid());
// max not set
Assert.assertFalse(BuiltinValidators.validatorConfigValidator().validate(new ValidatorConfig(ImmutableMap.of(MultiValueValidator.KEY_MIN, "10")), MultiValueValidator.ID, new ValidationContext(session)).toResult().isValid());
}
} }

View file

@ -1 +1,2 @@
org.keycloak.testsuite.forms.** org.keycloak.testsuite.forms.**
org.keycloak.testsuite.actions.RequiredActionUpdateProfileTest

View file

@ -104,10 +104,10 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
Assert.assertEquals(getFirstName(username), updateProfilePage.getFirstName()); Assert.assertEquals(getFirstName(username), updateProfilePage.getFirstName());
Assert.assertEquals(getLastName(username), updateProfilePage.getLastName()); Assert.assertEquals(getLastName(username), updateProfilePage.getLastName());
Assert.assertEquals(getEmail(username), updateProfilePage.getEmail()); Assert.assertEquals(getEmail(username), updateProfilePage.getEmail());
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.FIRST_NAME).isEnabled()); Assert.assertFalse(updateProfilePage.getElementById(UserModel.FIRST_NAME).isEnabled());
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.LAST_NAME).isEnabled()); Assert.assertFalse(updateProfilePage.getElementById(UserModel.LAST_NAME).isEnabled());
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled()); Assert.assertFalse(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled()); Assert.assertFalse(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
updateProfilePage.prepareUpdate().submit(); updateProfilePage.prepareUpdate().submit();
// check events // check events
@ -148,10 +148,10 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
Assert.assertEquals("Tom", updateProfilePage.getFirstName()); Assert.assertEquals("Tom", updateProfilePage.getFirstName());
Assert.assertEquals("Brady", updateProfilePage.getLastName()); Assert.assertEquals("Brady", updateProfilePage.getLastName());
Assert.assertEquals("test-user@localhost", updateProfilePage.getEmail()); Assert.assertEquals("test-user@localhost", updateProfilePage.getEmail());
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.FIRST_NAME).isEnabled()); Assert.assertTrue(updateProfilePage.getElementById(UserModel.FIRST_NAME).isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.LAST_NAME).isEnabled()); Assert.assertTrue(updateProfilePage.getElementById(UserModel.LAST_NAME).isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled()); Assert.assertTrue(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled()); Assert.assertTrue(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
updateProfilePage.prepareUpdate().submit(); updateProfilePage.prepareUpdate().submit();
// check events // check events
@ -196,11 +196,11 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
updateProfilePage.assertCurrent(); updateProfilePage.assertCurrent();
Assert.assertEquals(getEmail(username), updateProfilePage.getEmail()); Assert.assertEquals(getEmail(username), updateProfilePage.getEmail());
Assert.assertNull(updateProfilePage.getFieldById(UserModel.FIRST_NAME)); Assert.assertNull(updateProfilePage.getElementById(UserModel.FIRST_NAME));
Assert.assertNull(updateProfilePage.getFieldById(UserModel.LAST_NAME)); Assert.assertNull(updateProfilePage.getElementById(UserModel.LAST_NAME));
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled()); Assert.assertFalse(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled()); Assert.assertFalse(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById("postal_code").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("postal_code").isEnabled());
updateProfilePage.prepareUpdate().otherProfileAttribute(Map.of("postal_code", "123456")).submit(); updateProfilePage.prepareUpdate().otherProfileAttribute(Map.of("postal_code", "123456")).submit();
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
appPage.assertCurrent(); appPage.assertCurrent();
@ -248,11 +248,11 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
updateProfilePage.assertCurrent(); updateProfilePage.assertCurrent();
Assert.assertEquals("test-user@localhost", updateProfilePage.getEmail()); Assert.assertEquals("test-user@localhost", updateProfilePage.getEmail());
Assert.assertNull(updateProfilePage.getFieldById(UserModel.FIRST_NAME)); Assert.assertNull(updateProfilePage.getElementById(UserModel.FIRST_NAME));
Assert.assertNull(updateProfilePage.getFieldById(UserModel.LAST_NAME)); Assert.assertNull(updateProfilePage.getElementById(UserModel.LAST_NAME));
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled()); Assert.assertTrue(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled()); Assert.assertTrue(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
Assert.assertTrue(updateProfilePage.getFieldById("postal_code").isEnabled()); Assert.assertTrue(updateProfilePage.getElementById("postal_code").isEnabled());
updateProfilePage.prepareUpdate().otherProfileAttribute(Map.of("postal_code", "123456")).submit(); updateProfilePage.prepareUpdate().otherProfileAttribute(Map.of("postal_code", "123456")).submit();
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
appPage.assertCurrent(); appPage.assertCurrent();

View file

@ -65,3 +65,4 @@ error-invalid-date=Attribute {0} is invalid date.
error-user-attribute-read-only=Attribute {0} is read only. error-user-attribute-read-only=Attribute {0} is read only.
error-username-invalid-character={0} contains invalid character. error-username-invalid-character={0} contains invalid character.
error-person-name-invalid-character={0} contains invalid character. error-person-name-invalid-character={0} contains invalid character.
error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most {2} value(s).

View file

@ -521,3 +521,4 @@ logoutConfirmHeader=Do you want to log out?
doLogout=Logout doLogout=Logout
readOnlyUsernameMessage=You can''t update your username as it is read-only. readOnlyUsernameMessage=You can''t update your username as it is read-only.
error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most {2} value(s).

View file

@ -0,0 +1,106 @@
const DATA_KC_MULTIVALUED = 'data-kcMultivalued';
const KC_ADD_ACTION_PREFIX = "kc-add-";
const KC_REMOVE_ACTION_PREFIX = "kc-remove-";
const KC_ACTION_CLASS = "pf-c-button pf-m-inline pf-m-link";
function createAddAction(element) {
const action = createAction("Add value",
KC_ADD_ACTION_PREFIX,
element,
() => {
const name = element.getAttribute("name");
const elements = getInputElementsByName().get(name);
const length = elements.length;
if (length === 0) {
return;
}
const lastNode = elements[length - 1];
const newNode = lastNode.cloneNode(true);
newNode.setAttribute("id", name + "-" + elements.length);
newNode.value = "";
lastNode.after(newNode);
render();
});
element.after(action);
}
function createRemoveAction(element, isLastElement) {
let text = "Remove";
if (isLastElement) {
text = text + " | ";
}
const action = createAction(text, KC_REMOVE_ACTION_PREFIX, element, () => {
removeActions(element);
element.remove();
render();
});
element.insertAdjacentElement('afterend', action);
}
function getInputElementsByName() {
const selector = document.querySelectorAll(`[${DATA_KC_MULTIVALUED}]`);
const elementsByName = new Map();
for (let element of Array.from(selector.values())) {
let name = element.getAttribute("name");
let elements = elementsByName.get(name);
if (!elements) {
elements = [];
elementsByName.set(name, elements);
}
elements.push(element);
}
return elementsByName;
}
function removeActions(element) {
for (let actionPrefix of [KC_ADD_ACTION_PREFIX, KC_REMOVE_ACTION_PREFIX]) {
const action = document.getElementById(actionPrefix + element.getAttribute("id"));
if (action) {
action.remove();
}
}
}
function createAction(text, type, element, onClick) {
const action = document.createElement("button")
action.setAttribute("id", type + element.getAttribute("id"));
action.setAttribute("type", "button");
action.innerText = text;
action.setAttribute("class", KC_ACTION_CLASS);
action.addEventListener("click", onClick);
return action;
}
function render() {
getInputElementsByName().forEach((elements, name) => {
elements.forEach((element, index) => {
removeActions(element);
element.setAttribute("id", name + "-" + index);
const lastNode = element === elements[elements.length - 1];
if (lastNode) {
createAddAction(element);
}
if (elements.length > 1) {
createRemoveAction(element, lastNode);
}
});
});
}
render();

View file

@ -1,14 +1,21 @@
// @ts-check
import { formatNumber } from "./common.js"; import { formatNumber } from "./common.js";
import { registerElementAnnotatedBy } from "./userProfile.js";
const DATA_KC_NUMBER_FORMAT = 'data-kcNumberFormat'; const KC_NUMBER_FORMAT = "kcNumberFormat";
document.querySelectorAll(`[${DATA_KC_NUMBER_FORMAT}]`) registerElementAnnotatedBy({
.forEach(input => { name: KC_NUMBER_FORMAT,
const format = input.getAttribute(DATA_KC_NUMBER_FORMAT); onAdd(element) {
const formatValue = () => {
const format = element.getAttribute(`data-${KC_NUMBER_FORMAT}`);
element.value = formatNumber(element.value, format);
};
input.addEventListener('keyup', (event) => { element.addEventListener("keyup", formatValue);
input.value = formatNumber(input.value, format);
}); formatValue();
input.value = formatNumber(input.value, format); return () => element.removeEventListener("keyup", formatValue);
},
}); });

View file

@ -1,15 +1,19 @@
// @ts-check
import { formatNumber } from "./common.js"; import { formatNumber } from "./common.js";
import { registerElementAnnotatedBy } from "./userProfile.js";
const DATA_KC_NUMBER_UNFORMAT = 'data-kcNumberUnFormat'; const KC_NUMBER_UNFORMAT = 'kcNumberUnFormat';
document.querySelectorAll(`[${DATA_KC_NUMBER_UNFORMAT}]`) registerElementAnnotatedBy({
.forEach(input => { name: KC_NUMBER_UNFORMAT,
onAdd(element) {
for (let form of document.forms) { for (let form of document.forms) {
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
const rawFormat = input.getAttribute(DATA_KC_NUMBER_UNFORMAT); const rawFormat = element.getAttribute(`data-${KC_NUMBER_UNFORMAT}`);
if (rawFormat) { if (rawFormat) {
input.value = formatNumber(input.value, rawFormat); element.value = formatNumber(element.value, rawFormat);
} }
}); });
} }
},
}); });

View file

@ -0,0 +1,71 @@
// @ts-check
/**
* @typedef {Object} AnnotationDescriptor
* @property {string} name - The name of the field to register (e.g. `numberFormat`).
* @property {(element: HTMLElement) => (() => void) | void} onAdd - The function to call when a new element is added to the DOM.
*/
const observer = new MutationObserver(onMutate);
observer.observe(document.body, { childList: true, subtree: true });
/** @type {AnnotationDescriptor[]} */
const descriptors = [];
/** @type {WeakMap<HTMLElement, () => void>} */
const cleanupFunctions = new WeakMap();
/**
* @param {AnnotationDescriptor} descriptor
*/
export function registerElementAnnotatedBy(descriptor) {
descriptors.push(descriptor);
document.querySelectorAll(`[data-${descriptor.name}]`).forEach((element) => {
if (element instanceof HTMLElement) {
handleNewElement(element, descriptor);
}
});
}
/**
* @type {MutationCallback}
*/
function onMutate(mutations) {
const removedNodes = mutations.flatMap((mutation) => Array.from(mutation.removedNodes));
for (const node of removedNodes) {
if (!(node instanceof HTMLElement)) {
continue;
}
const handleRemovedElement = cleanupFunctions.get(node);
if (handleRemovedElement) {
handleRemovedElement();
}
cleanupFunctions.delete(node);
}
const addedNodes = mutations.flatMap((mutation) => Array.from(mutation.addedNodes));
for (const descriptor of descriptors) {
for (const node of addedNodes) {
if (node instanceof HTMLElement && node.hasAttribute(`data-${descriptor.name}`)) {
handleNewElement(node, descriptor);
}
}
}
}
/**
* @param {HTMLElement} element
* @param {AnnotationDescriptor} descriptor
*/
function handleNewElement(element, descriptor) {
const cleanup = descriptor.onAdd(element);
if (cleanup) {
cleanupFunctions.set(element, cleanup);
}
}

View file

@ -3,27 +3,31 @@
<#list profile.attributes as attribute> <#list profile.attributes as attribute>
<#assign groupName = attribute.group!""> <#assign group = (attribute.group)!"">
<#if groupName != currentGroup> <#if group != currentGroup>
<#assign currentGroup=groupName> <#assign currentGroup=group>
<#if currentGroup != ""> <#if currentGroup != "">
<div class="${properties.kcFormGroupClass!}"> <div class="${properties.kcFormGroupClass!}"
<#list group.html5DataAnnotations as key, value>
data-${key}="${value}"
</#list>
>
<#assign groupDisplayHeader=attribute.groupDisplayHeader!""> <#assign groupDisplayHeader=group.displayHeader!"">
<#if groupDisplayHeader != ""> <#if groupDisplayHeader != "">
<#assign groupHeaderText=advancedMsg(attribute.groupDisplayHeader)!groupName> <#assign groupHeaderText=advancedMsg(groupDisplayHeader)!group>
<#else> <#else>
<#assign groupHeaderText=groupName> <#assign groupHeaderText=group.name!"">
</#if> </#if>
<div class="${properties.kcContentWrapperClass!}"> <div class="${properties.kcContentWrapperClass!}">
<label id="header-${groupName}" class="${kcFormGroupHeader!}">${groupHeaderText}</label> <label id="header-${attribute.group.name}" class="${kcFormGroupHeader!}">${groupHeaderText}</label>
</div> </div>
<#assign groupDisplayDescription=attribute.groupDisplayDescription!""> <#assign groupDisplayDescription=group.displayDescription!"">
<#if groupDisplayDescription != ""> <#if groupDisplayDescription != "">
<#assign groupDescriptionText=advancedMsg(attribute.groupDisplayDescription)!""> <#assign groupDescriptionText=advancedMsg(groupDisplayDescription)!"">
<div class="${properties.kcLabelWrapperClass!}"> <div class="${properties.kcLabelWrapperClass!}">
<label id="description-${groupName}" class="${properties.kcLabelClass!}">${groupDescriptionText}</label> <label id="description-${group.name}" class="${properties.kcLabelClass!}">${groupDescriptionText}</label>
</div> </div>
</#if> </#if>
</div> </div>
@ -73,12 +77,18 @@
<@inputTagSelects attribute=attribute/> <@inputTagSelects attribute=attribute/>
<#break> <#break>
<#default> <#default>
<@inputTag attribute=attribute/> <#if attribute.multivalued && attribute.values?has_content>
<#list attribute.values as value>
<@inputTag attribute=attribute value=value!''/>
</#list>
<#else>
<@inputTag attribute=attribute value=attribute.value!''/>
</#if>
</#switch> </#switch>
</#macro> </#macro>
<#macro inputTag attribute> <#macro inputTag attribute value>
<input type="<@inputTagType attribute=attribute/>" id="${attribute.name}" name="${attribute.name}" value="${(attribute.value!'')}" class="${properties.kcInputClass!}" <input type="<@inputTagType attribute=attribute/>" id="${attribute.name}" name="${attribute.name}" value="${(value!'')}" class="${properties.kcInputClass!}"
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>" aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
<#if attribute.readOnly>disabled</#if> <#if attribute.readOnly>disabled</#if>
<#if attribute.autocomplete??>autocomplete="${attribute.autocomplete}"</#if> <#if attribute.autocomplete??>autocomplete="${attribute.autocomplete}"</#if>