group.attributes.length > 0)
- .map(({ group, attributes }) => ({
- title: group.displayHeader || group.name || t("general"),
- panel: (
-
- {group.displayDescription && (
- {group.displayDescription}
- )}
- {attributes.map((attribute) => (
-
- ))}
-
- ),
- }))}
- />
- );
-};
-
-type FormFieldProps = {
- form: UseFormReturn;
- attribute: UserProfileAttributeMetadata;
- roles: string[];
-};
-
-const FormField = ({ form, attribute, roles }: FormFieldProps) => {
- const value = form.watch(fieldName(attribute) as FieldPath);
- const inputType = determineInputType(attribute, value);
- const Component = FIELDS[inputType];
-
- return (
-
- );
-};
-
-const DEFAULT_INPUT_TYPE = "multiselect" satisfies InputType;
-
-function determineInputType(
- attribute: UserProfileAttributeMetadata,
- value: string | string[],
-): InputType {
- // Always treat the root attributes as a text field.
- if (isRootAttribute(attribute.name)) {
- return "text";
- }
-
- const inputType = attribute.annotations?.inputType;
-
- // If the attribute has no valid input type, it is always multi-valued.
- if (!isValidInputType(inputType)) {
- return DEFAULT_INPUT_TYPE;
- }
-
- // An attribute with multiple values is always multi-valued, even if an input type is provided.
- if (Array.isArray(value) && value.length > 1) {
- return DEFAULT_INPUT_TYPE;
- }
-
- return inputType;
-}
-
-const isValidInputType = (value: unknown): value is InputType =>
- typeof value === "string" && value in FIELDS;
diff --git a/js/apps/admin-ui/src/user/components/OptionsComponent.tsx b/js/apps/admin-ui/src/user/components/OptionsComponent.tsx
deleted file mode 100644
index ab6c6e54d17d..000000000000
--- a/js/apps/admin-ui/src/user/components/OptionsComponent.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Checkbox, Radio } from "@patternfly/react-core";
-import { Controller, FieldPath } from "react-hook-form";
-import { isRequiredAttribute } from "../utils/user-profile";
-
-import { Options, UserProfileFieldProps } from "../UserProfileFields";
-import { UserFormFields } from "../form-state";
-import { fieldName } from "../utils";
-import { UserProfileGroup } from "./UserProfileGroup";
-
-export const OptionComponent = ({
- form,
- inputType,
- attribute,
-}: UserProfileFieldProps) => {
- const isRequired = isRequiredAttribute(attribute);
- const isMultiSelect = inputType.startsWith("multiselect");
- const Component = isMultiSelect ? Checkbox : Radio;
- const options = (attribute.validators?.options as Options).options || [];
-
- return (
-
- }
- control={form.control}
- defaultValue=""
- render={({ field }) => (
- <>
- {options.map((option) => (
- {
- if (isMultiSelect) {
- if (field.value.includes(option)) {
- field.onChange(
- field.value.filter((item: string) => item !== option),
- );
- } else {
- field.onChange([...field.value, option]);
- }
- } else {
- field.onChange([option]);
- }
- }}
- readOnly={attribute.readOnly}
- isRequired={isRequired}
- />
- ))}
- >
- )}
- />
-
- );
-};
diff --git a/js/apps/admin-ui/src/user/components/SelectComponent.tsx b/js/apps/admin-ui/src/user/components/SelectComponent.tsx
deleted file mode 100644
index 0cf83e9f431c..000000000000
--- a/js/apps/admin-ui/src/user/components/SelectComponent.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Select, SelectOption } from "@patternfly/react-core";
-import { useState } from "react";
-import { Controller, ControllerRenderProps, FieldPath } from "react-hook-form";
-import { useTranslation } from "react-i18next";
-
-import { Options, UserProfileFieldProps } from "../UserProfileFields";
-import { UserFormFields } from "../form-state";
-import { fieldName, unWrap } from "../utils";
-import { UserProfileGroup } from "./UserProfileGroup";
-import { isRequiredAttribute } from "../utils/user-profile";
-
-type OptionLabel = Record | undefined;
-export const SelectComponent = ({
- form,
- inputType,
- attribute,
-}: UserProfileFieldProps) => {
- const { t } = useTranslation();
- const [open, setOpen] = useState(false);
- const isRequired = isRequiredAttribute(attribute);
- const isMultiValue = inputType === "multiselect";
-
- const setValue = (
- value: string,
- field: ControllerRenderProps,
- ) => {
- if (isMultiValue) {
- if (field.value.includes(value)) {
- field.onChange(field.value.filter((item: string) => item !== value));
- } else {
- field.onChange([...field.value, value]);
- }
- } else {
- field.onChange(value);
- }
- };
-
- const options =
- (attribute.validators?.options as Options | undefined)?.options || [];
-
- const optionLabel = attribute.annotations?.[
- "inputOptionLabels"
- ] as OptionLabel;
- const label = (label: string) =>
- optionLabel ? t(unWrap(optionLabel[label])) : label;
-
- return (
-
- }
- defaultValue=""
- control={form.control}
- render={({ field }) => (
-
- )}
- />
-
- );
-};
diff --git a/js/apps/admin-ui/src/user/components/TextAreaComponent.tsx b/js/apps/admin-ui/src/user/components/TextAreaComponent.tsx
deleted file mode 100644
index 865138d13020..000000000000
--- a/js/apps/admin-ui/src/user/components/TextAreaComponent.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { FieldPath } from "react-hook-form";
-
-import { KeycloakTextArea } from "../../components/keycloak-text-area/KeycloakTextArea";
-import { UserProfileFieldProps } from "../UserProfileFields";
-import { UserFormFields } from "../form-state";
-import { fieldName } from "../utils";
-import { isRequiredAttribute } from "../utils/user-profile";
-import { UserProfileGroup } from "./UserProfileGroup";
-
-export const TextAreaComponent = ({
- form,
- attribute,
-}: UserProfileFieldProps) => {
- const isRequired = isRequiredAttribute(attribute);
-
- return (
-
- )}
- cols={attribute.annotations?.["inputTypeCols"] as number}
- rows={attribute.annotations?.["inputTypeRows"] as number}
- readOnly={attribute.readOnly}
- isRequired={isRequired}
- />
-
- );
-};
diff --git a/js/apps/admin-ui/src/user/components/TextComponent.tsx b/js/apps/admin-ui/src/user/components/TextComponent.tsx
deleted file mode 100644
index 4c171d598ae7..000000000000
--- a/js/apps/admin-ui/src/user/components/TextComponent.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { TextInputTypes } from "@patternfly/react-core";
-import { FieldPath } from "react-hook-form";
-import { KeycloakTextInput } from "ui-shared";
-
-import { UserProfileFieldProps } from "../UserProfileFields";
-import { UserFormFields } from "../form-state";
-import { fieldName } from "../utils";
-import { isRequiredAttribute } from "../utils/user-profile";
-import { UserProfileGroup } from "./UserProfileGroup";
-
-export const TextComponent = ({
- form,
- inputType,
- attribute,
-}: UserProfileFieldProps) => {
- const isRequired = isRequiredAttribute(attribute);
- const type = inputType.startsWith("html")
- ? (inputType.substring("html".length + 2) as TextInputTypes)
- : "text";
-
- return (
-
- )}
- />
-
- );
-};
diff --git a/js/apps/admin-ui/src/user/components/UserProfileGroup.tsx b/js/apps/admin-ui/src/user/components/UserProfileGroup.tsx
deleted file mode 100644
index 43eac917daf8..000000000000
--- a/js/apps/admin-ui/src/user/components/UserProfileGroup.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { UserProfileAttributeMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
-import { FormGroup } from "@patternfly/react-core";
-import { PropsWithChildren } from "react";
-import { UseFormReturn } from "react-hook-form";
-import { useTranslation } from "react-i18next";
-import { HelpItem } from "ui-shared";
-
-import { UserFormFields } from "../form-state";
-import { label } from "../utils";
-import { isRequiredAttribute } from "../utils/user-profile";
-
-export type UserProfileGroupProps = {
- form: UseFormReturn;
- attribute: UserProfileAttributeMetadata;
-};
-
-export const UserProfileGroup = ({
- form,
- attribute,
- children,
-}: PropsWithChildren) => {
- const { t } = useTranslation();
- const helpText = attribute.annotations?.["inputHelperTextBefore"] as string;
- const {
- formState: { errors },
- } = form;
-
- return (
-
- ) : undefined
- }
- >
- {children}
-
- );
-};
diff --git a/js/libs/ui-shared/package.json b/js/libs/ui-shared/package.json
index 35d63654ff45..ae89f439d12c 100644
--- a/js/libs/ui-shared/package.json
+++ b/js/libs/ui-shared/package.json
@@ -36,12 +36,14 @@
"dependencies": {
"@patternfly/react-core": "^4.276.8",
"@patternfly/react-icons": "^4.93.6",
+ "lodash-es": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "7.47.0"
},
"devDependencies": {
- "@types/react": "^18.2.28",
+ "@types/lodash-es": "^4.17.9",
+ "@types/react": "^18.2.22",
"@types/react-dom": "^18.2.13",
"@vitejs/plugin-react-swc": "^3.4.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
diff --git a/js/libs/ui-shared/src/main.ts b/js/libs/ui-shared/src/main.ts
index 62fa21df5625..c87e258c09cd 100644
--- a/js/libs/ui-shared/src/main.ts
+++ b/js/libs/ui-shared/src/main.ts
@@ -15,3 +15,7 @@ export { isDefined } from "./utils/isDefined";
export { createNamedContext } from "./utils/createNamedContext";
export { useRequiredContext } from "./utils/useRequiredContext";
export { UserProfileFields } from "./user-profile/UserProfileFields";
+export {
+ setUserProfileServerError,
+ isUserProfileError,
+} from "./user-profile/utils";
diff --git a/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx b/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx
index a13373360fa0..3e874722f5f9 100644
--- a/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx
+++ b/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx
@@ -15,7 +15,7 @@ export const OptionComponent = (attr: UserProfileFieldsProps) => {
return (
(
diff --git a/js/libs/ui-shared/src/user-profile/SelectComponent.tsx b/js/libs/ui-shared/src/user-profile/SelectComponent.tsx
index 3b7ca8207bdc..6dc22e07a4ff 100644
--- a/js/libs/ui-shared/src/user-profile/SelectComponent.tsx
+++ b/js/libs/ui-shared/src/user-profile/SelectComponent.tsx
@@ -51,7 +51,7 @@ export const SelectComponent = (attr: UserProfileFieldsProps) => {
return (
(
diff --git a/js/libs/ui-shared/src/user-profile/TextAreaComponent.tsx b/js/libs/ui-shared/src/user-profile/TextAreaComponent.tsx
index 2fa7c72f0a7e..da8c3b529cb0 100644
--- a/js/libs/ui-shared/src/user-profile/TextAreaComponent.tsx
+++ b/js/libs/ui-shared/src/user-profile/TextAreaComponent.tsx
@@ -11,7 +11,7 @@ export const TextAreaComponent = (attr: UserProfileFieldsProps) => {
{
type={type}
placeholder={attr.annotations?.["inputTypePlaceholder"] as string}
readOnly={attr.readOnly}
- {...register(fieldName(attr))}
+ {...register(fieldName(attr.name))}
/>
);
diff --git a/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx b/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx
index 410dafb69a6b..df445d51e3d6 100644
--- a/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx
+++ b/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx
@@ -7,6 +7,7 @@ import { TextComponent } from "./TextComponent";
import {
UserProfileMetadata,
UserProfileAttributeMetadata,
+ UserProfileAttribute,
} from "./userProfileConfig";
import { TranslationFunction, fieldName } from "./utils";
@@ -14,6 +15,7 @@ type UserProfileFieldsProps = {
t: TranslationFunction;
metaData: UserProfileMetadata;
supportedLocales: string[];
+ renderer?: (attribute: UserProfileAttribute) => JSX.Element | undefined;
};
export type Options = {
@@ -71,28 +73,22 @@ export const UserProfileFields = ({
));
-type FormFieldProps = {
- t: TranslationFunction;
- supportedLocales: string[];
+export type FormFieldProps = Omit & {
attribute: UserProfileAttributeMetadata;
};
const FormField = (attr: FormFieldProps) => {
const { attribute, supportedLocales, t } = attr;
const { watch } = useFormContext();
- const value = watch(fieldName(attr));
+ const value = watch(fieldName(attribute.name));
const componentType = (attribute.annotations?.["inputType"] ||
- (Array.isArray(value) ? "multiselect" : "text")) as Field;
+ (Array.isArray(value) && value.length > 1
+ ? "multiselect"
+ : "text")) as Field;
const Component = FIELDS[componentType];
if (attribute.name === "locale")
return ;
- return (
-
- );
+ return ;
};
diff --git a/js/libs/ui-shared/src/user-profile/UserProfileGroup.tsx b/js/libs/ui-shared/src/user-profile/UserProfileGroup.tsx
index 8eff7bdb4f5e..301d19f7082e 100644
--- a/js/libs/ui-shared/src/user-profile/UserProfileGroup.tsx
+++ b/js/libs/ui-shared/src/user-profile/UserProfileGroup.tsx
@@ -1,13 +1,14 @@
-import { FormGroup } from "@patternfly/react-core";
+import { FormGroup, InputGroup } from "@patternfly/react-core";
+import { get } from "lodash-es";
import { PropsWithChildren } from "react";
import { useFormContext } from "react-hook-form";
import { HelpItem } from "../controls/HelpItem";
+import { FormFieldProps } from "./UserProfileFields";
import { UserProfileAttribute } from "./userProfileConfig";
-import { TranslationFunction, label } from "./utils";
+import { fieldName, label } from "./utils";
-export type UserProfileFieldsProps = UserProfileAttribute & {
- t: TranslationFunction;
-};
+export type UserProfileFieldsProps = Omit &
+ UserProfileAttribute;
type LengthValidator =
| {
@@ -16,11 +17,12 @@ type LengthValidator =
| undefined;
const isRequired = (attribute: UserProfileAttribute) =>
- Object.keys(attribute.required || {}).length !== 0 ||
+ !!attribute.required ||
(((attribute.validators?.length as LengthValidator)?.min as number) || 0) > 0;
export const UserProfileGroup = ({
children,
+ renderer,
...attribute
}: PropsWithChildren) => {
const { t } = attribute;
@@ -30,22 +32,24 @@ export const UserProfileGroup = ({
formState: { errors },
} = useFormContext();
- console.log("label", label(attribute));
return (
) : undefined
}
>
- {children}
+
+ {children}
+ {renderer?.(attribute)}
+
);
};
diff --git a/js/libs/ui-shared/src/user-profile/userProfileConfig.d.ts b/js/libs/ui-shared/src/user-profile/userProfileConfig.d.ts
index f2db7502e871..3980f9cdc004 100644
--- a/js/libs/ui-shared/src/user-profile/userProfileConfig.d.ts
+++ b/js/libs/ui-shared/src/user-profile/userProfileConfig.d.ts
@@ -55,3 +55,13 @@ export interface UserRepresentation {
userProfileMetadata: UserProfileMetadata;
attributes: { [index: string]: string[] };
}
+
+type FieldError = {
+ field: string;
+ errorMessage: string;
+ params?: string[];
+};
+
+export type UserProfileError = {
+ responseData?: { errors?: FieldError[] };
+};
diff --git a/js/libs/ui-shared/src/user-profile/utils.ts b/js/libs/ui-shared/src/user-profile/utils.ts
index 924a78c884e5..39d9e64d06d7 100644
--- a/js/libs/ui-shared/src/user-profile/utils.ts
+++ b/js/libs/ui-shared/src/user-profile/utils.ts
@@ -1,4 +1,5 @@
import { UserProfileFieldsProps } from "./UserProfileGroup";
+import { FieldError, UserProfileError } from "./userProfileConfig";
export const isBundleKey = (displayName?: string) =>
displayName?.includes("${");
@@ -14,7 +15,32 @@ const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
const isRootAttribute = (attr?: string) =>
attr && ROOT_ATTRIBUTES.includes(attr);
-export const fieldName = (attribute: UserProfileFieldsProps) =>
- `${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`;
+export const fieldName = (name?: string) =>
+ `${isRootAttribute(name) ? "" : "attributes."}${name}`;
-export type TranslationFunction = (key: unknown) => string;
+export function setUserProfileServerError(
+ error: unknown,
+ setError: (field: keyof T, params: object) => void,
+ t: TranslationFunction,
+) {
+ (error as FieldError[]).forEach((e) => {
+ const params = Object.assign(
+ {},
+ e.params?.map((p) => t(isBundleKey(p) ? unWrap(p) : p)),
+ );
+ console.log("why", params, e.errorMessage);
+ setError(fieldName(e.field) as keyof T, {
+ message: t(e.errorMessage, {
+ ...params,
+ defaultValue: e.field,
+ }),
+ type: "server",
+ });
+ });
+}
+
+export function isUserProfileError(error: unknown): error is UserProfileError {
+ return !!(error as UserProfileError).responseData?.errors;
+}
+
+export type TranslationFunction = (key: unknown, params?: object) => string;
diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml
index 506e3bc85b21..c85f11b03b27 100644
--- a/js/pnpm-lock.yaml
+++ b/js/pnpm-lock.yaml
@@ -433,6 +433,9 @@ importers:
'@patternfly/react-icons':
specifier: ^4.93.6
version: 4.93.6(react-dom@18.2.0)(react@18.2.0)
+ lodash-es:
+ specifier: ^4.17.21
+ version: 4.17.21
react:
specifier: ^18.2.0
version: 18.2.0
@@ -443,8 +446,11 @@ importers:
specifier: 7.47.0
version: 7.47.0(react@18.2.0)
devDependencies:
+ '@types/lodash-es':
+ specifier: ^4.17.9
+ version: 4.17.9
'@types/react':
- specifier: ^18.2.28
+ specifier: ^18.2.22
version: 18.2.28
'@types/react-dom':
specifier: ^18.2.13
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AdminMessageFormatter.java b/services/src/main/java/org/keycloak/services/resources/admin/AdminMessageFormatter.java
deleted file mode 100644
index a68177ee0a5e..000000000000
--- a/services/src/main/java/org/keycloak/services/resources/admin/AdminMessageFormatter.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright 2021 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.services.resources.admin;
-
-import java.io.IOException;
-import java.text.MessageFormat;
-import java.util.Locale;
-import java.util.Properties;
-import java.util.function.BiFunction;
-
-import org.keycloak.models.KeycloakContext;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.RealmModel;
-import org.keycloak.models.UserModel;
-import org.keycloak.theme.Theme;
-
-/**
- * Message formatter for Admin GUI/API messages.
- *
- * @author Vlastimil Elias
- *
- */
-public class AdminMessageFormatter implements BiFunction {
-
- private final Locale locale;
- private final Properties messages;
-
- /**
- * @param session to get context (including current Realm) from
- * @param user to resolve locale for
- */
- public AdminMessageFormatter(KeycloakSession session, UserModel user) {
- try {
- KeycloakContext context = session.getContext();
- locale = context.resolveLocale(user);
- RealmModel realm = context.getRealm();
- messages = getTheme(session).getEnhancedMessages(realm, locale);
- } catch (IOException cause) {
- throw new RuntimeException("Failed to configure error messages", cause);
- }
- }
-
- private Theme getTheme(KeycloakSession session) throws IOException {
- return session.theme().getTheme(Theme.Type.ADMIN);
- }
-
- @Override
- public String apply(String s, Object[] objects) {
- return new MessageFormat(messages.getProperty(s, s), locale).format(objects);
- }
-}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java
index 64edfe885f49..a8ecbb46583e 100755
--- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java
@@ -245,10 +245,8 @@ public static Response validateUserProfile(UserProfile profile, KeycloakSession
profile.validate();
} catch (ValidationException pve) {
List errors = new ArrayList<>();
- AdminMessageFormatter adminMessageFormatter = createAdminMessageFormatter(session, adminAuth);
-
for (ValidationException.Error error : pve.getErrors()) {
- errors.add(new ErrorRepresentation(error.getFormattedMessage(adminMessageFormatter)));
+ errors.add(new ErrorRepresentation(error.getAttribute(), error.getMessage(), error.getMessageParameters()));
}
throw ErrorResponse.errors(errors, Status.BAD_REQUEST);
@@ -257,13 +255,6 @@ public static Response validateUserProfile(UserProfile profile, KeycloakSession
return null;
}
- private static AdminMessageFormatter createAdminMessageFormatter(KeycloakSession session, AdminAuth adminAuth) {
- // the authenticated user is used to resolve the locale for the messages. It can be null.
- UserModel authenticatedUser = adminAuth == null ? null : adminAuth.getUser();
-
- return new AdminMessageFormatter(session, authenticatedUser);
- }
-
public static void updateUserFromRep(UserProfile profile, UserModel user, UserRepresentation rep, KeycloakSession session, boolean isUpdateExistingUser) {
boolean removeMissingRequiredActions = isUpdateExistingUser;