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, 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,315 +84,330 @@ export const AttributeGeneralSettings = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormAccess role="manage-realm" isHorizontal>
|
<FormAccess role="manage-realm" isHorizontal>
|
||||||
<FormGroup
|
<FormProvider {...form}>
|
||||||
label={t("attributeName")}
|
<FormGroup
|
||||||
labelIcon={
|
label={t("attributeName")}
|
||||||
<HelpItem
|
labelIcon={
|
||||||
helpText={t("upAttributeNameHelp")}
|
<HelpItem
|
||||||
fieldLabelId="attributeName"
|
helpText={t("upAttributeNameHelp")}
|
||||||
/>
|
fieldLabelId="attributeName"
|
||||||
}
|
/>
|
||||||
fieldId="kc-attribute-name"
|
}
|
||||||
isRequired
|
fieldId="kc-attribute-name"
|
||||||
validated={form.formState.errors.name ? "error" : "default"}
|
|
||||||
helperTextInvalid={t("validateAttributeName")}
|
|
||||||
>
|
|
||||||
<KeycloakTextInput
|
|
||||||
isRequired
|
isRequired
|
||||||
id="kc-attribute-name"
|
|
||||||
defaultValue=""
|
|
||||||
data-testid="attribute-name"
|
|
||||||
isDisabled={editMode}
|
|
||||||
validated={form.formState.errors.name ? "error" : "default"}
|
validated={form.formState.errors.name ? "error" : "default"}
|
||||||
{...form.register("name", { required: true })}
|
helperTextInvalid={t("validateAttributeName")}
|
||||||
/>
|
>
|
||||||
</FormGroup>
|
<KeycloakTextInput
|
||||||
<FormGroup
|
isRequired
|
||||||
label={t("attributeDisplayName")}
|
id="kc-attribute-name"
|
||||||
labelIcon={
|
defaultValue=""
|
||||||
<HelpItem
|
data-testid="attribute-name"
|
||||||
helpText={t("attributeDisplayNameHelp")}
|
isDisabled={editMode}
|
||||||
fieldLabelId="attributeDisplayName"
|
validated={form.formState.errors.name ? "error" : "default"}
|
||||||
|
{...form.register("name", { required: true })}
|
||||||
/>
|
/>
|
||||||
}
|
</FormGroup>
|
||||||
fieldId="kc-attribute-display-name"
|
<FormGroup
|
||||||
>
|
label={t("attributeDisplayName")}
|
||||||
<KeycloakTextInput
|
labelIcon={
|
||||||
id="kc-attribute-display-name"
|
<HelpItem
|
||||||
defaultValue=""
|
helpText={t("attributeDisplayNameHelp")}
|
||||||
data-testid="attribute-display-name"
|
fieldLabelId="attributeDisplayName"
|
||||||
{...form.register("displayName")}
|
/>
|
||||||
/>
|
}
|
||||||
</FormGroup>
|
fieldId="kc-attribute-display-name"
|
||||||
<FormGroup
|
>
|
||||||
label={t("attributeGroup")}
|
<KeycloakTextInput
|
||||||
labelIcon={
|
id="kc-attribute-display-name"
|
||||||
<HelpItem
|
defaultValue=""
|
||||||
helpText={t("attributeGroupHelp")}
|
data-testid="attribute-display-name"
|
||||||
fieldLabelId="realm-setting:attributeGroup"
|
{...form.register("displayName")}
|
||||||
/>
|
/>
|
||||||
}
|
</FormGroup>
|
||||||
fieldId="kc-attributeGroup"
|
<DefaultSwitchControl
|
||||||
>
|
name="multivalued"
|
||||||
<Controller
|
label={t("multivalued")}
|
||||||
name="group"
|
labelIcon={t("multivaluedHelp")}
|
||||||
defaultValue=""
|
/>
|
||||||
control={form.control}
|
<FormGroup
|
||||||
render={({ field }) => (
|
label={t("attributeGroup")}
|
||||||
<Select
|
labelIcon={
|
||||||
toggleId="kc-attributeGroup"
|
<HelpItem
|
||||||
aria-label={t("attributeGroup")}
|
helpText={t("attributeGroupHelp")}
|
||||||
onToggle={() =>
|
fieldLabelId="realm-setting:attributeGroup"
|
||||||
setIsAttributeGroupDropdownOpen(!isAttributeGroupDropdownOpen)
|
/>
|
||||||
|
}
|
||||||
|
fieldId="kc-attributeGroup"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="group"
|
||||||
|
defaultValue=""
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
toggleId="kc-attributeGroup"
|
||||||
|
aria-label={t("attributeGroup")}
|
||||||
|
onToggle={() =>
|
||||||
|
setIsAttributeGroupDropdownOpen(!isAttributeGroupDropdownOpen)
|
||||||
|
}
|
||||||
|
isOpen={isAttributeGroupDropdownOpen}
|
||||||
|
onSelect={(_, value) => {
|
||||||
|
field.onChange(value.toString());
|
||||||
|
setIsAttributeGroupDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
selections={[field.value || t("none")]}
|
||||||
|
variant={SelectVariant.single}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
<SelectOption key="empty" value="">
|
||||||
|
{t("none")}
|
||||||
|
</SelectOption>,
|
||||||
|
...(config?.groups?.map((group) => (
|
||||||
|
<SelectOption key={group.name} value={group.name}>
|
||||||
|
{group.name}
|
||||||
|
</SelectOption>
|
||||||
|
)) || []),
|
||||||
|
]}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
></Controller>
|
||||||
|
</FormGroup>
|
||||||
|
{!USERNAME_EMAIL.includes(attributeName) && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<FormGroup
|
||||||
|
label={t("enabledWhen")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText={t("enabledWhenTooltip")}
|
||||||
|
fieldLabelId="enabled-when"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
isOpen={isAttributeGroupDropdownOpen}
|
fieldId="enabledWhen"
|
||||||
onSelect={(_, value) => {
|
hasNoPaddingTop
|
||||||
field.onChange(value.toString());
|
|
||||||
setIsAttributeGroupDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
selections={[field.value || t("none")]}
|
|
||||||
variant={SelectVariant.single}
|
|
||||||
>
|
>
|
||||||
{[
|
<Radio
|
||||||
<SelectOption key="empty" value="">
|
id="always"
|
||||||
{t("none")}
|
data-testid="always"
|
||||||
</SelectOption>,
|
isChecked={!hasSelector}
|
||||||
...(config?.groups?.map((group) => (
|
name="enabledWhen"
|
||||||
<SelectOption key={group.name} value={group.name}>
|
label={t("always")}
|
||||||
{group.name}
|
onChange={() => setHasSelector(false)}
|
||||||
</SelectOption>
|
className="pf-u-mb-md"
|
||||||
)) || []),
|
|
||||||
]}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
></Controller>
|
|
||||||
</FormGroup>
|
|
||||||
{!USERNAME_EMAIL.includes(attributeName) && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<FormGroup
|
|
||||||
label={t("enabledWhen")}
|
|
||||||
labelIcon={
|
|
||||||
<HelpItem
|
|
||||||
helpText={t("enabledWhenTooltip")}
|
|
||||||
fieldLabelId="enabled-when"
|
|
||||||
/>
|
/>
|
||||||
}
|
<Radio
|
||||||
fieldId="enabledWhen"
|
id="scopesAsRequested"
|
||||||
hasNoPaddingTop
|
data-testid="scopesAsRequested"
|
||||||
>
|
isChecked={hasSelector}
|
||||||
<Radio
|
name="enabledWhen"
|
||||||
id="always"
|
label={t("scopesAsRequested")}
|
||||||
data-testid="always"
|
onChange={() => setHasSelector(true)}
|
||||||
isChecked={!hasSelector}
|
className="pf-u-mb-md"
|
||||||
name="enabledWhen"
|
|
||||||
label={t("always")}
|
|
||||||
onChange={() => setHasSelector(false)}
|
|
||||||
className="pf-u-mb-md"
|
|
||||||
/>
|
|
||||||
<Radio
|
|
||||||
id="scopesAsRequested"
|
|
||||||
data-testid="scopesAsRequested"
|
|
||||||
isChecked={hasSelector}
|
|
||||||
name="enabledWhen"
|
|
||||||
label={t("scopesAsRequested")}
|
|
||||||
onChange={() => setHasSelector(true)}
|
|
||||||
className="pf-u-mb-md"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
{hasSelector && (
|
|
||||||
<FormGroup fieldId="kc-scope-enabled-when">
|
|
||||||
<Controller
|
|
||||||
name="selector.scopes"
|
|
||||||
control={form.control}
|
|
||||||
defaultValue={[]}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
name="scopes"
|
|
||||||
data-testid="enabled-when-scope-field"
|
|
||||||
variant={SelectVariant.typeaheadMulti}
|
|
||||||
typeAheadAriaLabel="Select"
|
|
||||||
chipGroupProps={{
|
|
||||||
numChips: 3,
|
|
||||||
expandedText: t("hide"),
|
|
||||||
collapsedText: t("showRemaining"),
|
|
||||||
}}
|
|
||||||
onToggle={(isOpen) => setSelectEnabledWhenOpen(isOpen)}
|
|
||||||
selections={field.value}
|
|
||||||
onSelect={(_, selectedValue) => {
|
|
||||||
const option = selectedValue.toString();
|
|
||||||
let changedValue = [""];
|
|
||||||
if (field.value) {
|
|
||||||
changedValue = field.value.includes(option)
|
|
||||||
? field.value.filter(
|
|
||||||
(item: string) => item !== option,
|
|
||||||
)
|
|
||||||
: [...field.value, option];
|
|
||||||
} else {
|
|
||||||
changedValue = [option];
|
|
||||||
}
|
|
||||||
|
|
||||||
field.onChange(changedValue);
|
|
||||||
}}
|
|
||||||
onClear={(selectedValues) => {
|
|
||||||
selectedValues.stopPropagation();
|
|
||||||
field.onChange([]);
|
|
||||||
}}
|
|
||||||
isOpen={selectEnabledWhenOpen}
|
|
||||||
aria-labelledby={"scope"}
|
|
||||||
>
|
|
||||||
{clientScopes.map((option) => (
|
|
||||||
<SelectOption key={option.name} value={option.name} />
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
{hasSelector && (
|
||||||
</>
|
<FormGroup fieldId="kc-scope-enabled-when">
|
||||||
)}
|
|
||||||
{attributeName !== "username" && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<FormGroup
|
|
||||||
label={t("required")}
|
|
||||||
labelIcon={
|
|
||||||
<HelpItem helpText={t("requiredHelp")} fieldLabelId="required" />
|
|
||||||
}
|
|
||||||
fieldId="kc-required"
|
|
||||||
hasNoPaddingTop
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="isRequired"
|
|
||||||
data-testid="required"
|
|
||||||
defaultValue={false}
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Switch
|
|
||||||
id={"kc-required"}
|
|
||||||
onChange={field.onChange}
|
|
||||||
isChecked={field.value}
|
|
||||||
label={t("on")}
|
|
||||||
labelOff={t("off")}
|
|
||||||
aria-label={t("required")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
{required && (
|
|
||||||
<>
|
|
||||||
<FormGroup
|
|
||||||
label={t("requiredFor")}
|
|
||||||
fieldId="requiredFor"
|
|
||||||
hasNoPaddingTop
|
|
||||||
>
|
|
||||||
<Controller
|
<Controller
|
||||||
name="required.roles"
|
name="selector.scopes"
|
||||||
data-testid="requiredFor"
|
|
||||||
defaultValue={REQUIRED_FOR[0].value}
|
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
defaultValue={[]}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="kc-requiredFor">
|
<Select
|
||||||
{REQUIRED_FOR.map((option) => (
|
name="scopes"
|
||||||
<Radio
|
data-testid="enabled-when-scope-field"
|
||||||
id={option.label}
|
variant={SelectVariant.typeaheadMulti}
|
||||||
key={option.label}
|
typeAheadAriaLabel="Select"
|
||||||
data-testid={option.label}
|
chipGroupProps={{
|
||||||
isChecked={isEqual(field.value, option.value)}
|
numChips: 3,
|
||||||
name="roles"
|
expandedText: t("hide"),
|
||||||
onChange={() => {
|
collapsedText: t("showRemaining"),
|
||||||
field.onChange(option.value);
|
}}
|
||||||
}}
|
onToggle={(isOpen) => setSelectEnabledWhenOpen(isOpen)}
|
||||||
label={t(option.label)}
|
selections={field.value}
|
||||||
className="kc-requiredFor-option"
|
onSelect={(_, selectedValue) => {
|
||||||
/>
|
const option = selectedValue.toString();
|
||||||
|
let changedValue = [""];
|
||||||
|
if (field.value) {
|
||||||
|
changedValue = field.value.includes(option)
|
||||||
|
? field.value.filter(
|
||||||
|
(item: string) => item !== option,
|
||||||
|
)
|
||||||
|
: [...field.value, option];
|
||||||
|
} else {
|
||||||
|
changedValue = [option];
|
||||||
|
}
|
||||||
|
|
||||||
|
field.onChange(changedValue);
|
||||||
|
}}
|
||||||
|
onClear={(selectedValues) => {
|
||||||
|
selectedValues.stopPropagation();
|
||||||
|
field.onChange([]);
|
||||||
|
}}
|
||||||
|
isOpen={selectEnabledWhenOpen}
|
||||||
|
aria-labelledby={"scope"}
|
||||||
|
>
|
||||||
|
{clientScopes.map((option) => (
|
||||||
|
<SelectOption key={option.name} value={option.name} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup
|
)}
|
||||||
label={t("requiredWhen")}
|
</>
|
||||||
labelIcon={
|
)}
|
||||||
<HelpItem
|
{attributeName !== "username" && (
|
||||||
helpText={t("requiredWhenTooltip")}
|
<>
|
||||||
fieldLabelId="required-when"
|
<Divider />
|
||||||
|
<FormGroup
|
||||||
|
label={t("required")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText={t("requiredHelp")}
|
||||||
|
fieldLabelId="required"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="kc-required"
|
||||||
|
hasNoPaddingTop
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="isRequired"
|
||||||
|
data-testid="required"
|
||||||
|
defaultValue={false}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
id={"kc-required"}
|
||||||
|
onChange={field.onChange}
|
||||||
|
isChecked={field.value}
|
||||||
|
label={t("on")}
|
||||||
|
labelOff={t("off")}
|
||||||
|
aria-label={t("required")}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
fieldId="requiredWhen"
|
/>
|
||||||
hasNoPaddingTop
|
</FormGroup>
|
||||||
>
|
{required && (
|
||||||
<Radio
|
<>
|
||||||
id="requiredAlways"
|
<FormGroup
|
||||||
data-testid="requiredAlways"
|
label={t("requiredFor")}
|
||||||
isChecked={!hasRequiredScopes}
|
fieldId="requiredFor"
|
||||||
name="requiredWhen"
|
hasNoPaddingTop
|
||||||
label={t("always")}
|
>
|
||||||
onChange={() => setHasRequiredScopes(false)}
|
|
||||||
className="pf-u-mb-md"
|
|
||||||
/>
|
|
||||||
<Radio
|
|
||||||
id="requiredScopesAsRequested"
|
|
||||||
data-testid="requiredScopesAsRequested"
|
|
||||||
isChecked={hasRequiredScopes}
|
|
||||||
name="requiredWhen"
|
|
||||||
label={t("scopesAsRequested")}
|
|
||||||
onChange={() => setHasRequiredScopes(true)}
|
|
||||||
className="pf-u-mb-md"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
{hasRequiredScopes && (
|
|
||||||
<FormGroup fieldId="kc-scope-required-when">
|
|
||||||
<Controller
|
<Controller
|
||||||
name="required.scopes"
|
name="required.roles"
|
||||||
|
data-testid="requiredFor"
|
||||||
|
defaultValue={REQUIRED_FOR[0].value}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
defaultValue={[]}
|
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<div className="kc-requiredFor">
|
||||||
name="scopeRequired"
|
{REQUIRED_FOR.map((option) => (
|
||||||
data-testid="required-when-scope-field"
|
<Radio
|
||||||
variant={SelectVariant.typeaheadMulti}
|
id={option.label}
|
||||||
typeAheadAriaLabel="Select"
|
key={option.label}
|
||||||
chipGroupProps={{
|
data-testid={option.label}
|
||||||
numChips: 3,
|
isChecked={isEqual(field.value, option.value)}
|
||||||
expandedText: t("hide"),
|
name="roles"
|
||||||
collapsedText: t("showRemaining"),
|
onChange={() => {
|
||||||
}}
|
field.onChange(option.value);
|
||||||
onToggle={(isOpen) => setSelectRequiredForOpen(isOpen)}
|
}}
|
||||||
selections={field.value}
|
label={t(option.label)}
|
||||||
onSelect={(_, selectedValue) => {
|
className="kc-requiredFor-option"
|
||||||
const option = selectedValue.toString();
|
/>
|
||||||
let changedValue = [""];
|
|
||||||
if (field.value) {
|
|
||||||
changedValue = field.value.includes(option)
|
|
||||||
? field.value.filter(
|
|
||||||
(item: string) => item !== option,
|
|
||||||
)
|
|
||||||
: [...field.value, option];
|
|
||||||
} else {
|
|
||||||
changedValue = [option];
|
|
||||||
}
|
|
||||||
field.onChange(changedValue);
|
|
||||||
}}
|
|
||||||
onClear={(selectedValues) => {
|
|
||||||
selectedValues.stopPropagation();
|
|
||||||
field.onChange([]);
|
|
||||||
}}
|
|
||||||
isOpen={selectRequiredForOpen}
|
|
||||||
aria-labelledby={"scope"}
|
|
||||||
>
|
|
||||||
{clientScopes.map((option) => (
|
|
||||||
<SelectOption key={option.name} value={option.name} />
|
|
||||||
))}
|
))}
|
||||||
</Select>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
<FormGroup
|
||||||
</>
|
label={t("requiredWhen")}
|
||||||
)}
|
labelIcon={
|
||||||
</>
|
<HelpItem
|
||||||
)}
|
helpText={t("requiredWhenTooltip")}
|
||||||
|
fieldLabelId="required-when"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="requiredWhen"
|
||||||
|
hasNoPaddingTop
|
||||||
|
>
|
||||||
|
<Radio
|
||||||
|
id="requiredAlways"
|
||||||
|
data-testid="requiredAlways"
|
||||||
|
isChecked={!hasRequiredScopes}
|
||||||
|
name="requiredWhen"
|
||||||
|
label={t("always")}
|
||||||
|
onChange={() => setHasRequiredScopes(false)}
|
||||||
|
className="pf-u-mb-md"
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
id="requiredScopesAsRequested"
|
||||||
|
data-testid="requiredScopesAsRequested"
|
||||||
|
isChecked={hasRequiredScopes}
|
||||||
|
name="requiredWhen"
|
||||||
|
label={t("scopesAsRequested")}
|
||||||
|
onChange={() => setHasRequiredScopes(true)}
|
||||||
|
className="pf-u-mb-md"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{hasRequiredScopes && (
|
||||||
|
<FormGroup fieldId="kc-scope-required-when">
|
||||||
|
<Controller
|
||||||
|
name="required.scopes"
|
||||||
|
control={form.control}
|
||||||
|
defaultValue={[]}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
name="scopeRequired"
|
||||||
|
data-testid="required-when-scope-field"
|
||||||
|
variant={SelectVariant.typeaheadMulti}
|
||||||
|
typeAheadAriaLabel="Select"
|
||||||
|
chipGroupProps={{
|
||||||
|
numChips: 3,
|
||||||
|
expandedText: t("hide"),
|
||||||
|
collapsedText: t("showRemaining"),
|
||||||
|
}}
|
||||||
|
onToggle={(isOpen) =>
|
||||||
|
setSelectRequiredForOpen(isOpen)
|
||||||
|
}
|
||||||
|
selections={field.value}
|
||||||
|
onSelect={(_, selectedValue) => {
|
||||||
|
const option = selectedValue.toString();
|
||||||
|
let changedValue = [""];
|
||||||
|
if (field.value) {
|
||||||
|
changedValue = field.value.includes(option)
|
||||||
|
? field.value.filter(
|
||||||
|
(item: string) => item !== option,
|
||||||
|
)
|
||||||
|
: [...field.value, option];
|
||||||
|
} else {
|
||||||
|
changedValue = [option];
|
||||||
|
}
|
||||||
|
field.onChange(changedValue);
|
||||||
|
}}
|
||||||
|
onClear={(selectedValues) => {
|
||||||
|
selectedValues.stopPropagation();
|
||||||
|
field.onChange([]);
|
||||||
|
}}
|
||||||
|
isOpen={selectRequiredForOpen}
|
||||||
|
aria-labelledby={"scope"}
|
||||||
|
>
|
||||||
|
{clientScopes.map((option) => (
|
||||||
|
<SelectOption
|
||||||
|
key={option.name}
|
||||||
|
value={option.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormProvider>
|
||||||
</FormAccess>
|
</FormAccess>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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){
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,4 +13,5 @@ org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidato
|
||||||
org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueValidator
|
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
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
org.keycloak.testsuite.forms.**
|
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(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();
|
||||||
|
|
|
@ -64,4 +64,5 @@ error-user-attribute-required=Please specify attribute {0}.
|
||||||
error-invalid-date=Attribute {0} is invalid date.
|
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).
|
|
@ -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).
|
|
@ -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 @@
|
||||||
import {formatNumber} from "./common.js";
|
// @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}]`)
|
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
input.value = formatNumber(input.value, format);
|
formatValue();
|
||||||
});
|
|
||||||
|
return () => element.removeEventListener("keyup", formatValue);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
import {formatNumber} from "./common.js";
|
// @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}]`)
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -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>
|
<#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>
|
||||||
|
|
Loading…
Reference in a new issue