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:
parent
1e12b15890
commit
604274fb76
40 changed files with 1283 additions and 455 deletions
|
@ -30,13 +30,14 @@ public class UserProfileAttributeMetadata {
|
|||
private Map<String, Object> annotations;
|
||||
private Map<String, Map<String, Object>> validators;
|
||||
private String group;
|
||||
private boolean multivalued;
|
||||
|
||||
public UserProfileAttributeMetadata() {
|
||||
|
||||
}
|
||||
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 `<THEME TYPE>/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 `<THEME TYPE>/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
|
||||
|
||||
|
|
|
@ -81,6 +81,7 @@ export interface UserProfileAttributeMetadata {
|
|||
readOnly: boolean;
|
||||
annotations?: { [index: string]: any };
|
||||
validators: { [index: string]: { [index: string]: any } };
|
||||
multivalued: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfileMetadata {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,6 +84,7 @@ export const AttributeGeneralSettings = () => {
|
|||
|
||||
return (
|
||||
<FormAccess role="manage-realm" isHorizontal>
|
||||
<FormProvider {...form}>
|
||||
<FormGroup
|
||||
label={t("attributeName")}
|
||||
labelIcon={
|
||||
|
@ -118,6 +125,11 @@ export const AttributeGeneralSettings = () => {
|
|||
{...form.register("displayName")}
|
||||
/>
|
||||
</FormGroup>
|
||||
<DefaultSwitchControl
|
||||
name="multivalued"
|
||||
label={t("multivalued")}
|
||||
labelIcon={t("multivaluedHelp")}
|
||||
/>
|
||||
<FormGroup
|
||||
label={t("attributeGroup")}
|
||||
labelIcon={
|
||||
|
@ -251,7 +263,10 @@ export const AttributeGeneralSettings = () => {
|
|||
<FormGroup
|
||||
label={t("required")}
|
||||
labelIcon={
|
||||
<HelpItem helpText={t("requiredHelp")} fieldLabelId="required" />
|
||||
<HelpItem
|
||||
helpText={t("requiredHelp")}
|
||||
fieldLabelId="required"
|
||||
/>
|
||||
}
|
||||
fieldId="kc-required"
|
||||
hasNoPaddingTop
|
||||
|
@ -352,7 +367,9 @@ export const AttributeGeneralSettings = () => {
|
|||
expandedText: t("hide"),
|
||||
collapsedText: t("showRemaining"),
|
||||
}}
|
||||
onToggle={(isOpen) => setSelectRequiredForOpen(isOpen)}
|
||||
onToggle={(isOpen) =>
|
||||
setSelectRequiredForOpen(isOpen)
|
||||
}
|
||||
selections={field.value}
|
||||
onSelect={(_, selectedValue) => {
|
||||
const option = selectedValue.toString();
|
||||
|
@ -376,7 +393,10 @@ export const AttributeGeneralSettings = () => {
|
|||
aria-labelledby={"scope"}
|
||||
>
|
||||
{clientScopes.map((option) => (
|
||||
<SelectOption key={option.name} value={option.name} />
|
||||
<SelectOption
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
@ -387,6 +407,7 @@ export const AttributeGeneralSettings = () => {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</FormAccess>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<string, unknown>;
|
||||
validators?: Record<string, Record<string, unknown>>;
|
||||
multivalued?: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfileAttributeGroupMetadata {
|
||||
|
|
|
@ -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) => (
|
||||
<UserProfileGroup t={t} form={form} attribute={attribute} renderer={renderer}>
|
||||
<MultiLineInput
|
||||
|
@ -29,6 +31,7 @@ export const MultiInputComponent = ({
|
|||
addButtonLabel={t("addMultivaluedLabel", {
|
||||
fieldLabel: labelAttribute(t, attribute),
|
||||
})}
|
||||
{...rest}
|
||||
/>
|
||||
</UserProfileGroup>
|
||||
);
|
||||
|
@ -40,11 +43,13 @@ export type MultiLineInputProps = Omit<TextInputProps, "form"> & {
|
|||
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}
|
||||
/>
|
||||
<Button
|
||||
|
|
|
@ -182,11 +182,12 @@ const FormField = ({
|
|||
const value = form.watch(
|
||||
fieldName(attribute.name) as FieldPath<UserFormFields>,
|
||||
);
|
||||
const inputType = useMemo(
|
||||
() => determineInputType(attribute, value),
|
||||
[attribute],
|
||||
);
|
||||
const Component = FIELDS[inputType];
|
||||
const inputType = useMemo(() => determineInputType(attribute), [attribute]);
|
||||
|
||||
const Component =
|
||||
attribute.multivalued || isMultiValue(value)
|
||||
? FIELDS["multi-input"]
|
||||
: FIELDS[inputType];
|
||||
|
||||
if (attribute.name === "locale")
|
||||
return (
|
||||
|
@ -212,7 +213,6 @@ const DEFAULT_INPUT_TYPE = "text" satisfies InputType;
|
|||
|
||||
function determineInputType(
|
||||
attribute: UserProfileAttributeMetadata,
|
||||
value: string | string[],
|
||||
): InputType {
|
||||
// Always treat the root attributes as a text field.
|
||||
if (isRootAttribute(attribute.name)) {
|
||||
|
@ -226,11 +226,6 @@ function determineInputType(
|
|||
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
|
||||
return DEFAULT_INPUT_TYPE;
|
||||
}
|
||||
|
|
|
@ -151,7 +151,8 @@ public class UserProfileUtil {
|
|||
profile.getAttributes().isReadOnly(am.getName()),
|
||||
group,
|
||||
am.getAnnotations(),
|
||||
toValidatorMetadata(am, session));
|
||||
toValidatorMetadata(am, session),
|
||||
am.isMultivalued());
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){
|
||||
|
|
|
@ -51,6 +51,7 @@ public class AttributeMetadata {
|
|||
private List<AttributeValidatorMetadata> validators;
|
||||
private Map<String, Object> annotations;
|
||||
private int guiOrder;
|
||||
private boolean multivalued;
|
||||
|
||||
|
||||
AttributeMetadata(String attributeName, int guiOrder) {
|
||||
|
@ -199,6 +200,14 @@ public class AttributeMetadata {
|
|||
return this;
|
||||
}
|
||||
|
||||
public void setMultivalued(boolean multivalued) {
|
||||
this.multivalued = multivalued;
|
||||
}
|
||||
|
||||
public boolean isMultivalued() {
|
||||
return multivalued;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AttributeMetadata clone() {
|
||||
AttributeMetadata cloned = new AttributeMetadata(attributeName, guiOrder, selector, writeAllowed, required, readAllowed);
|
||||
|
@ -215,6 +224,7 @@ public class AttributeMetadata {
|
|||
if (attributeGroupMetadata != null) {
|
||||
cloned.setAttributeGroupMetadata(attributeGroupMetadata.clone());
|
||||
}
|
||||
cloned.setMultivalued(multivalued);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,14 @@ public class ValidationResult {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
|
@ -20,6 +20,7 @@ import java.util.Collections;
|
|||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
@ -35,6 +36,10 @@ public class ValidatorConfig {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
|
@ -217,7 +217,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
|
|||
|
||||
attributes.put("properties", theme.getProperties());
|
||||
attributes.put("realmName", getRealmName());
|
||||
attributes.put("user", new ProfileBean(user));
|
||||
attributes.put("user", new ProfileBean(user, session));
|
||||
KeycloakUriInfo uriInfo = session.getContext().getUri();
|
||||
attributes.put("url", new UrlBean(realm, theme, uriInfo.getBaseUri(), null));
|
||||
|
||||
|
|
|
@ -17,7 +17,11 @@
|
|||
package org.keycloak.email.freemarker.beans;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
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.List;
|
||||
|
@ -34,17 +38,25 @@ public class ProfileBean {
|
|||
private UserModel user;
|
||||
private final Map<String, String> attributes = new HashMap<>();
|
||||
|
||||
public ProfileBean(UserModel user) {
|
||||
public ProfileBean(UserModel user, KeycloakSession session) {
|
||||
this.user = user;
|
||||
|
||||
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()) {
|
||||
List<String> attrValue = attr.getValue();
|
||||
if (attrValue != null && attrValue.size() > 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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
package org.keycloak.forms.login.freemarker.model;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
@ -11,6 +15,7 @@ import java.util.stream.Stream;
|
|||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.userprofile.AttributeGroupMetadata;
|
||||
import org.keycloak.userprofile.AttributeMetadata;
|
||||
import org.keycloak.userprofile.AttributeValidatorMetadata;
|
||||
import org.keycloak.userprofile.Attributes;
|
||||
|
@ -24,6 +29,22 @@ import org.keycloak.userprofile.UserProfileProvider;
|
|||
*/
|
||||
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 UserProfile profile;
|
||||
protected List<Attribute> attributes;
|
||||
|
@ -104,7 +125,7 @@ public abstract class AbstractUserProfileBean {
|
|||
.filter((am) -> writeableOnly ? !profileAttributes.isReadOnly(am.getName()) : true)
|
||||
.filter((am) -> !profileAttributes.getUnmanagedAttributes().containsKey(am.getName()))
|
||||
.map(Attribute::new)
|
||||
.sorted()
|
||||
.sorted(ATTRIBUTE_COMPARATOR)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
@ -127,6 +148,10 @@ public abstract class AbstractUserProfileBean {
|
|||
return metadata.getAttributeDisplayName();
|
||||
}
|
||||
|
||||
public boolean isMultivalued() {
|
||||
return metadata.isMultivalued();
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
List<String> v = getValues();
|
||||
if (v == null || v.isEmpty()) {
|
||||
|
@ -177,8 +202,16 @@ public abstract class AbstractUserProfileBean {
|
|||
}
|
||||
|
||||
public Map<String, Object> getHtml5DataAnnotations() {
|
||||
return getAnnotations().entrySet().stream()
|
||||
.filter((entry) -> entry.getKey().startsWith("kc")).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
Map<String, Object> groupAnnotations = Optional.ofNullable(getGroup()).map(AttributeGroup::getAnnotations).orElse(Map.of());
|
||||
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));
|
||||
}
|
||||
|
||||
public String getGroup() {
|
||||
if (metadata.getAttributeGroupMetadata() != null) {
|
||||
return metadata.getAttributeGroupMetadata().getName();
|
||||
public AttributeGroup getGroup() {
|
||||
AttributeGroupMetadata groupMetadata = metadata.getAttributeGroupMetadata();
|
||||
|
||||
if (groupMetadata != null) {
|
||||
return new AttributeGroup(groupMetadata);
|
||||
}
|
||||
|
||||
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
|
||||
public int compareTo(Attribute o) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ import org.keycloak.userprofile.config.UPConfigUtils;
|
|||
import org.keycloak.representations.userprofile.config.UPGroup;
|
||||
import org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator;
|
||||
import org.keycloak.userprofile.validator.ImmutableAttributeValidator;
|
||||
import org.keycloak.userprofile.validator.MultiValueValidator;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.validate.AbstractSimpleValidator;
|
||||
import org.keycloak.validate.ValidatorConfig;
|
||||
|
@ -341,6 +342,13 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
|
|||
|
||||
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)) {
|
||||
// make sure username and email are writable if permissions are not set
|
||||
if (permissions == null || permissions.isEmpty()) {
|
||||
|
@ -391,13 +399,15 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
|
|||
.addReadCondition(readAllowed)
|
||||
.addWriteCondition(writeAllowed)
|
||||
.addValidators(validators)
|
||||
.setRequired(required);
|
||||
.setRequired(required)
|
||||
.setMultivalued(attrConfig.isMultivalued());
|
||||
}
|
||||
} else {
|
||||
decoratedMetadata.addAttribute(attributeName, guiOrder, validators, selector, writeAllowed, required, readAllowed)
|
||||
.addAnnotations(annotations)
|
||||
.setAttributeDisplayName(attrConfig.getDisplayName())
|
||||
.setAttributeGroupMetadata(groupMetadata);
|
||||
.setAttributeGroupMetadata(groupMetadata)
|
||||
.setMultivalued(attrConfig.isMultivalued());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -14,3 +14,4 @@ org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueValidator
|
|||
org.keycloak.userprofile.validator.ImmutableAttributeValidator
|
||||
org.keycloak.userprofile.validator.UsernameProhibitedCharactersValidator
|
||||
org.keycloak.userprofile.validator.PersonNameProhibitedCharactersValidator
|
||||
org.keycloak.userprofile.validator.MultiValueValidator
|
|
@ -25,8 +25,10 @@ import java.util.Map;
|
|||
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.keycloak.testsuite.util.UIUtils;
|
||||
import org.keycloak.testsuite.util.WaitUtils;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.NoSuchElementException;
|
||||
import org.openqa.selenium.TimeoutException;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.FindBy;
|
||||
|
||||
|
@ -119,10 +121,12 @@ public class LoginUpdateProfilePage extends AbstractPage {
|
|||
return driver.findElement(By.cssSelector("label[for="+fieldId+"]")).getText();
|
||||
}
|
||||
|
||||
public WebElement getFieldById(String fieldId) {
|
||||
public WebElement getElementById(String fieldId) {
|
||||
try {
|
||||
return driver.findElement(By.id(fieldId));
|
||||
} catch (NoSuchElementException nsee) {
|
||||
By id = By.id(fieldId);
|
||||
WaitUtils.waitUntilElement(id);
|
||||
return driver.findElement(id);
|
||||
} catch (NoSuchElementException | TimeoutException ignore) {
|
||||
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 {
|
||||
private final LoginUpdateProfilePage page;
|
||||
private String firstName;
|
||||
|
|
|
@ -416,7 +416,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
|
|||
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
|
||||
+ "{\"name\": \"attr1\"," + PERMISSIONS_ALL + "},"
|
||||
+ "{\"name\": \"attr2\"," + PERMISSIONS_ALL + "}"
|
||||
+ "{\"name\": \"attr2\"," + PERMISSIONS_ALL + ", \"multivalued\": true}"
|
||||
+ "]}";
|
||||
setUserProfileConfiguration(userProfileCfg);
|
||||
|
||||
|
|
|
@ -19,10 +19,18 @@ package org.keycloak.testsuite.actions;
|
|||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.anyOf;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
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.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.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.Assert;
|
||||
|
@ -37,10 +45,13 @@ import org.keycloak.models.UserModel;
|
|||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
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.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
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.RequestType;
|
||||
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.util.UserBuilder;
|
||||
import org.keycloak.userprofile.UserProfileContext;
|
||||
import org.openqa.selenium.htmlunit.HtmlUnitDriver;
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -174,45 +174,45 @@ public class RequiredActionUpdateProfileWithUserProfileTest extends AbstractTest
|
|||
updateProfilePage.assertCurrent();
|
||||
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(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3714,6 +3714,7 @@ public class UserTest extends AbstractAdminTest {
|
|||
private UPAttribute createAttributeMetadata(String name) {
|
||||
UPAttribute attribute = new UPAttribute();
|
||||
attribute.setName(name);
|
||||
attribute.setMultivalued(true);
|
||||
UPAttributePermissions permissions = new UPAttributePermissions();
|
||||
permissions.setEdit(Set.of("user", "admin"));
|
||||
attribute.setPermissions(permissions);
|
||||
|
|
|
@ -407,45 +407,45 @@ public class KcOidcFirstBrokerLoginTest extends AbstractFirstBrokerLoginTest {
|
|||
//assert fields location in 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(
|
||||
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()
|
||||
);
|
||||
org.junit.Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
org.junit.Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
org.junit.Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
org.junit.Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
org.junit.Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
org.junit.Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
org.junit.Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -107,9 +107,9 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
|
|||
updateProfilePage.assertCurrent();
|
||||
Assert.assertEquals("John", updateProfilePage.getFirstName());
|
||||
Assert.assertEquals("Doe", updateProfilePage.getLastName());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getFieldById("postal_code"));
|
||||
Assert.assertTrue(updateProfilePage.getElementById("firstName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById("lastName").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getElementById("postal_code"));
|
||||
updateProfilePage.prepareUpdate().submit();
|
||||
}
|
||||
|
||||
|
@ -145,10 +145,10 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
|
|||
|
||||
Assert.assertEquals("John", updateProfilePage.getFirstName());
|
||||
Assert.assertEquals("Doe", updateProfilePage.getLastName());
|
||||
Assert.assertEquals("1234", updateProfilePage.getFieldById("postal_code").getAttribute("value"));
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("postal_code").isEnabled());
|
||||
Assert.assertEquals("1234", updateProfilePage.getElementById("postal_code").getAttribute("value"));
|
||||
Assert.assertTrue(updateProfilePage.getElementById("firstName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById("lastName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById("postal_code").isEnabled());
|
||||
updateProfilePage.prepareUpdate().submit();
|
||||
} finally {
|
||||
testRealm().users().userProfile().update(origConfig);
|
||||
|
@ -175,9 +175,9 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
|
|||
updateProfilePage.assertCurrent();
|
||||
Assert.assertEquals("John", updateProfilePage.getFirstName());
|
||||
Assert.assertEquals("Doe", updateProfilePage.getLastName());
|
||||
Assert.assertFalse(updateProfilePage.getFieldById("firstName").isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getFieldById("lastName").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getFieldById("postal_code"));
|
||||
Assert.assertFalse(updateProfilePage.getElementById("firstName").isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getElementById("lastName").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getElementById("postal_code"));
|
||||
updateProfilePage.prepareUpdate().submit();
|
||||
} finally {
|
||||
setLDAPWritable();
|
||||
|
@ -206,9 +206,9 @@ public class LDAPUserProfileTest extends AbstractLDAPTest {
|
|||
updateProfilePage.assertCurrent();
|
||||
Assert.assertEquals("Mary", updateProfilePage.getFirstName());
|
||||
Assert.assertEquals("Kelly", updateProfilePage.getLastName());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getFieldById("postal_code"));
|
||||
Assert.assertTrue(updateProfilePage.getElementById("firstName").isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById("lastName").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getElementById("postal_code"));
|
||||
updateProfilePage.prepareUpdate().submit();
|
||||
} finally {
|
||||
setLDAPWritable();
|
||||
|
|
|
@ -442,55 +442,46 @@ public class RegisterWithUserProfileTest extends AbstractTestRealmKeycloakTest {
|
|||
registerPage.assertCurrent();
|
||||
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(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
driver.findElement(
|
||||
By.cssSelector("#password")
|
||||
By.cssSelector("form#"+htmlFormId+" > div:nth-child(2) > div:nth-child(2) > input#department")
|
||||
).isDisplayed()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
driver.findElement(
|
||||
By.cssSelector("#password-confirm")
|
||||
By.cssSelector("form#"+htmlFormId+" > div:nth-child(3) > div:nth-child(1) > label#header-contact")
|
||||
).isDisplayed()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
// firstname order is after username, so it will render after password and password confirmation fields
|
||||
Assert.assertTrue(
|
||||
driver.findElement(
|
||||
By.cssSelector("form#"+htmlFormId+" > div:nth-child(7) > div:nth-child(2) > input#department")
|
||||
).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")
|
||||
By.cssSelector("form#"+htmlFormId+" > div:nth-child(9) > div:nth-child(2) > input#firstName")
|
||||
).isDisplayed()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -220,45 +220,45 @@ public class VerifyProfileTest extends AbstractTestRealmKeycloakTest {
|
|||
verifyProfilePage.assertCurrent();
|
||||
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(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
Assert.assertTrue(
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ import java.util.Optional;
|
|||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.hamcrest.Matchers;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
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.ValidationException;
|
||||
import org.keycloak.userprofile.config.UPConfigUtils;
|
||||
import org.keycloak.userprofile.validator.MultiValueValidator;
|
||||
import org.keycloak.userprofile.validator.PersonNameProhibitedCharactersValidator;
|
||||
import org.keycloak.userprofile.validator.UsernameIDNHomographValidator;
|
||||
import org.keycloak.validate.ValidationError;
|
||||
|
@ -2169,4 +2171,77 @@ public class UserProfileTest extends AbstractUserProfileTest {
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ import org.keycloak.models.KeycloakSession;
|
|||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||
import org.keycloak.testsuite.arquillian.annotation.ModelTest;
|
||||
import org.keycloak.userprofile.validator.MultiValueValidator;
|
||||
import org.keycloak.validate.AbstractSimpleValidator;
|
||||
import org.keycloak.validate.ValidationContext;
|
||||
import org.keycloak.validate.ValidationError;
|
||||
|
@ -577,4 +578,42 @@ public class BuiltinValidatorsTest extends AbstractKeycloakTest {
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
org.keycloak.testsuite.forms.**
|
||||
org.keycloak.testsuite.actions.RequiredActionUpdateProfileTest
|
|
@ -104,10 +104,10 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
|
|||
Assert.assertEquals(getFirstName(username), updateProfilePage.getFirstName());
|
||||
Assert.assertEquals(getLastName(username), updateProfilePage.getLastName());
|
||||
Assert.assertEquals(getEmail(username), updateProfilePage.getEmail());
|
||||
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.FIRST_NAME).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.LAST_NAME).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getElementById(UserModel.FIRST_NAME).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getElementById(UserModel.LAST_NAME).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
|
||||
updateProfilePage.prepareUpdate().submit();
|
||||
|
||||
// check events
|
||||
|
@ -148,10 +148,10 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
|
|||
Assert.assertEquals("Tom", updateProfilePage.getFirstName());
|
||||
Assert.assertEquals("Brady", updateProfilePage.getLastName());
|
||||
Assert.assertEquals("test-user@localhost", updateProfilePage.getEmail());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.FIRST_NAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.LAST_NAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById(UserModel.FIRST_NAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById(UserModel.LAST_NAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
|
||||
updateProfilePage.prepareUpdate().submit();
|
||||
|
||||
// check events
|
||||
|
@ -196,11 +196,11 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
|
|||
WaitUtils.waitForPageToLoad();
|
||||
updateProfilePage.assertCurrent();
|
||||
Assert.assertEquals(getEmail(username), updateProfilePage.getEmail());
|
||||
Assert.assertNull(updateProfilePage.getFieldById(UserModel.FIRST_NAME));
|
||||
Assert.assertNull(updateProfilePage.getFieldById(UserModel.LAST_NAME));
|
||||
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("postal_code").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getElementById(UserModel.FIRST_NAME));
|
||||
Assert.assertNull(updateProfilePage.getElementById(UserModel.LAST_NAME));
|
||||
Assert.assertFalse(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertFalse(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById("postal_code").isEnabled());
|
||||
updateProfilePage.prepareUpdate().otherProfileAttribute(Map.of("postal_code", "123456")).submit();
|
||||
WaitUtils.waitForPageToLoad();
|
||||
appPage.assertCurrent();
|
||||
|
@ -248,11 +248,11 @@ public class SSSDUserProfileTest extends AbstractBaseSSSDTest {
|
|||
WaitUtils.waitForPageToLoad();
|
||||
updateProfilePage.assertCurrent();
|
||||
Assert.assertEquals("test-user@localhost", updateProfilePage.getEmail());
|
||||
Assert.assertNull(updateProfilePage.getFieldById(UserModel.FIRST_NAME));
|
||||
Assert.assertNull(updateProfilePage.getFieldById(UserModel.LAST_NAME));
|
||||
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById(UserModel.USERNAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getFieldById("postal_code").isEnabled());
|
||||
Assert.assertNull(updateProfilePage.getElementById(UserModel.FIRST_NAME));
|
||||
Assert.assertNull(updateProfilePage.getElementById(UserModel.LAST_NAME));
|
||||
Assert.assertTrue(updateProfilePage.getElementById(UserModel.EMAIL).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById(UserModel.USERNAME).isEnabled());
|
||||
Assert.assertTrue(updateProfilePage.getElementById("postal_code").isEnabled());
|
||||
updateProfilePage.prepareUpdate().otherProfileAttribute(Map.of("postal_code", "123456")).submit();
|
||||
WaitUtils.waitForPageToLoad();
|
||||
appPage.assertCurrent();
|
||||
|
|
|
@ -65,3 +65,4 @@ error-invalid-date=Attribute {0} is invalid date.
|
|||
error-user-attribute-read-only=Attribute {0} is read only.
|
||||
error-username-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).
|
|
@ -521,3 +521,4 @@ logoutConfirmHeader=Do you want to log out?
|
|||
doLogout=Logout
|
||||
|
||||
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).
|
|
@ -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();
|
|
@ -1,14 +1,21 @@
|
|||
// @ts-check
|
||||
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}]`)
|
||||
.forEach(input => {
|
||||
const format = input.getAttribute(DATA_KC_NUMBER_FORMAT);
|
||||
registerElementAnnotatedBy({
|
||||
name: 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) => {
|
||||
input.value = formatNumber(input.value, format);
|
||||
});
|
||||
|
||||
input.value = formatNumber(input.value, format);
|
||||
element.addEventListener("keyup", formatValue);
|
||||
|
||||
formatValue();
|
||||
|
||||
return () => element.removeEventListener("keyup", formatValue);
|
||||
},
|
||||
});
|
|
@ -1,15 +1,19 @@
|
|||
// @ts-check
|
||||
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}]`)
|
||||
.forEach(input => {
|
||||
registerElementAnnotatedBy({
|
||||
name: KC_NUMBER_UNFORMAT,
|
||||
onAdd(element) {
|
||||
for (let form of document.forms) {
|
||||
form.addEventListener('submit', (event) => {
|
||||
const rawFormat = input.getAttribute(DATA_KC_NUMBER_UNFORMAT);
|
||||
const rawFormat = element.getAttribute(`data-${KC_NUMBER_UNFORMAT}`);
|
||||
if (rawFormat) {
|
||||
input.value = formatNumber(input.value, rawFormat);
|
||||
element.value = formatNumber(element.value, rawFormat);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -3,27 +3,31 @@
|
|||
|
||||
<#list profile.attributes as attribute>
|
||||
|
||||
<#assign groupName = attribute.group!"">
|
||||
<#if groupName != currentGroup>
|
||||
<#assign currentGroup=groupName>
|
||||
<#assign group = (attribute.group)!"">
|
||||
<#if group != currentGroup>
|
||||
<#assign currentGroup=group>
|
||||
<#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 != "">
|
||||
<#assign groupHeaderText=advancedMsg(attribute.groupDisplayHeader)!groupName>
|
||||
<#assign groupHeaderText=advancedMsg(groupDisplayHeader)!group>
|
||||
<#else>
|
||||
<#assign groupHeaderText=groupName>
|
||||
<#assign groupHeaderText=group.name!"">
|
||||
</#if>
|
||||
<div class="${properties.kcContentWrapperClass!}">
|
||||
<label id="header-${groupName}" class="${kcFormGroupHeader!}">${groupHeaderText}</label>
|
||||
<label id="header-${attribute.group.name}" class="${kcFormGroupHeader!}">${groupHeaderText}</label>
|
||||
</div>
|
||||
|
||||
<#assign groupDisplayDescription=attribute.groupDisplayDescription!"">
|
||||
<#assign groupDisplayDescription=group.displayDescription!"">
|
||||
<#if groupDisplayDescription != "">
|
||||
<#assign groupDescriptionText=advancedMsg(attribute.groupDisplayDescription)!"">
|
||||
<#assign groupDescriptionText=advancedMsg(groupDisplayDescription)!"">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label id="description-${groupName}" class="${properties.kcLabelClass!}">${groupDescriptionText}</label>
|
||||
<label id="description-${group.name}" class="${properties.kcLabelClass!}">${groupDescriptionText}</label>
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
|
@ -73,12 +77,18 @@
|
|||
<@inputTagSelects attribute=attribute/>
|
||||
<#break>
|
||||
<#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>
|
||||
</#macro>
|
||||
|
||||
<#macro inputTag attribute>
|
||||
<input type="<@inputTagType attribute=attribute/>" id="${attribute.name}" name="${attribute.name}" value="${(attribute.value!'')}" class="${properties.kcInputClass!}"
|
||||
<#macro inputTag attribute value>
|
||||
<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>"
|
||||
<#if attribute.readOnly>disabled</#if>
|
||||
<#if attribute.autocomplete??>autocomplete="${attribute.autocomplete}"</#if>
|
||||
|
|
Loading…
Reference in a new issue