From bce8270e7f9f5b4044c710b2625d229b55ac1013 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Thu, 17 Nov 2022 11:14:47 -0500 Subject: [PATCH] Added user profile attributes to user detail screen (#3762) --- .../attribute/AddValidatorDialog.tsx | 24 +- .../attribute/AddValidatorRoleDialog.tsx | 25 +- .../attribute/AttributeValidations.tsx | 2 +- .../user-profile/attribute/Validators.ts | 171 ------------ apps/admin-ui/src/user/UserAttributes.tsx | 14 +- apps/admin-ui/src/user/UserForm.tsx | 249 +++++++++--------- apps/admin-ui/src/user/UserProfileFields.tsx | 150 +++++++++++ apps/admin-ui/src/user/UsersTabs.tsx | 179 +++++++------ 8 files changed, 411 insertions(+), 403 deletions(-) delete mode 100644 apps/admin-ui/src/realm-settings/user-profile/attribute/Validators.ts create mode 100644 apps/admin-ui/src/user/UserProfileFields.tsx diff --git a/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorDialog.tsx b/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorDialog.tsx index d533e0e479..8870e34960 100644 --- a/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorDialog.tsx +++ b/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorDialog.tsx @@ -17,13 +17,14 @@ import { import type { IndexedValidations } from "../../NewAttributeSettings"; import { AddValidatorRoleDialog } from "./AddValidatorRoleDialog"; -import { Validator, validators as allValidator } from "./Validators"; import useToggle from "../../../utils/useToggle"; +import { useServerInfo } from "../../../context/server-info/ServerInfoProvider"; +import ComponentTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentTypeRepresentation"; export type AddValidatorDialogProps = { selectedValidators: IndexedValidations[]; toggleDialog: () => void; - onConfirm: (newValidator: Validator) => void; + onConfirm: (newValidator: ComponentTypeRepresentation) => void; }; export const AddValidatorDialog = ({ @@ -32,10 +33,13 @@ export const AddValidatorDialog = ({ onConfirm, }: AddValidatorDialogProps) => { const { t } = useTranslation("realm-settings"); - const [selectedValidator, setSelectedValidator] = useState(); - const [validators, setValidators] = useState(() => + const [selectedValidator, setSelectedValidator] = + useState(); + const allValidator: ComponentTypeRepresentation[] = + useServerInfo().componentTypes?.["org.keycloak.validate.Validator"] || []; + const [validators, setValidators] = useState( allValidator.filter( - ({ name }) => !selectedValidators.map(({ key }) => key).includes(name) + ({ id }) => !selectedValidators.map(({ key }) => key).includes(id) ) ); const [addValidatorRoleModalOpen, toggleModal] = useToggle(); @@ -47,7 +51,7 @@ export const AddValidatorDialog = ({ onConfirm={(newValidator) => { onConfirm(newValidator); setValidators( - validators.filter(({ name }) => name !== newValidator.name) + validators.filter(({ id }) => id !== newValidator.id) ); }} open={addValidatorRoleModalOpen} @@ -70,9 +74,9 @@ export const AddValidatorDialog = ({ - {validators.map((validator) => ( + {allValidator.map((validator) => ( { setSelectedValidator(validator); toggleModal(); @@ -80,10 +84,10 @@ export const AddValidatorDialog = ({ isHoverable > - {validator.name} + {validator.id} - {validator.description} + {validator.helpText} ))} diff --git a/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorRoleDialog.tsx b/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorRoleDialog.tsx index 1bfc97a477..883c04832e 100644 --- a/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorRoleDialog.tsx +++ b/apps/admin-ui/src/realm-settings/user-profile/attribute/AddValidatorRoleDialog.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next"; -import { Button, Modal, ModalVariant } from "@patternfly/react-core"; import { FormProvider, useForm } from "react-hook-form"; +import { Button, Form, Modal, ModalVariant } from "@patternfly/react-core"; + +import type ComponentTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentTypeRepresentation"; import { DynamicComponents } from "../../../components/dynamic/DynamicComponents"; -import type { Validator } from "./Validators"; export type AddValidatorRoleDialogProps = { open: boolean; toggleDialog: () => void; - onConfirm: (newValidator: Validator) => void; - selected: Validator; + onConfirm: (newValidator: ComponentTypeRepresentation) => void; + selected: ComponentTypeRepresentation; }; export const AddValidatorRoleDialog = ({ @@ -22,8 +23,8 @@ export const AddValidatorRoleDialog = ({ const { handleSubmit } = form; const selectedRoleValidator = selected; - const save = (newValidator: Validator) => { - onConfirm({ ...newValidator, name: selected.name }); + const save = (newValidator: ComponentTypeRepresentation) => { + onConfirm({ ...newValidator, id: selected.id }); toggleDialog(); }; @@ -31,9 +32,9 @@ export const AddValidatorRoleDialog = ({ , ]} > - - - +
+ + + +
); }; diff --git a/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeValidations.tsx b/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeValidations.tsx index b660892c76..59290cfd56 100644 --- a/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeValidations.tsx +++ b/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeValidations.tsx @@ -67,7 +67,7 @@ export const AttributeValidations = () => { onConfirm={(newValidator) => { setValue("validations", [ ...validators, - { key: newValidator.name, value: newValidator.config }, + { key: newValidator.id, value: newValidator.properties }, ]); }} toggleDialog={toggleModal} diff --git a/apps/admin-ui/src/realm-settings/user-profile/attribute/Validators.ts b/apps/admin-ui/src/realm-settings/user-profile/attribute/Validators.ts deleted file mode 100644 index 29899ad3b4..0000000000 --- a/apps/admin-ui/src/realm-settings/user-profile/attribute/Validators.ts +++ /dev/null @@ -1,171 +0,0 @@ -export type Validator = { - name: string; - description?: string; - config?: ValidatorConfig[]; -}; - -export type ValidatorConfig = { - name?: string; - label?: string; - helpText?: string; - type?: string; - defaultValue?: any; - options?: string[]; - secret?: boolean; -}; - -export const validators: Validator[] = [ - { - name: "double", - description: - "Check if the value is a double and within a lower and/or upper range. If no range is defined, the validator only checks whether the value is a valid number.", - config: [ - { - type: "String", - defaultValue: "", - helpText: "The minimal allowed value - this config is optional.", - label: "Minimum", - name: "min", - }, - { - type: "String", - defaultValue: "", - helpText: "The maximal allowed value - this config is optional.", - label: "Maximum", - name: "max", - }, - ], - }, - { - name: "email", - description: "Check if the value has a valid e-mail format.", - config: [], - }, - { - name: "integer", - description: - "Check if the value is an integer and within a lower and/or upper range. If no range is defined, the validator only checks whether the value is a valid number.", - config: [ - { - type: "String", - defaultValue: "", - helpText: "The minimal allowed value - this config is optional.", - label: "Minimum", - name: "min", - }, - { - type: "String", - defaultValue: "", - helpText: "The maximal allowed value - this config is optional.", - label: "Maximum", - name: "max", - }, - ], - }, - { - name: "length", - description: - "Check the length of a string value based on a minimum and maximum length.", - config: [ - { - type: "String", - defaultValue: "", - helpText: "The minimum length", - label: "Minimum length", - name: "min", - }, - { - type: "String", - defaultValue: "", - helpText: "The maximum length", - label: "Maximum length", - name: "max", - }, - { - type: "boolean", - defaultValue: false, - helpText: - "Disable trimming of the String value before the length check", - label: "Trimming disabled", - name: "trim-disabled", - }, - ], - }, - { - name: "local-date", - description: - "Check if the value has a valid format based on the realm and/or user locale.", - config: [], - }, - { - name: "options", - description: - "Check if the value is from the defined set of allowed values. Useful to validate values entered through select and multiselect fields.", - config: [ - { - type: "MultivaluedString", - defaultValue: "", - helpText: "List of allowed options", - label: "Options", - name: "options", - }, - ], - }, - { - name: "pattern", - description: "Check if the value matches a specific RegEx pattern.", - config: [ - { - type: "String", - defaultValue: "", - helpText: - "RegExp pattern the value must match. Java Pattern syntax is used.", - label: "RegExp pattern", - name: "pattern", - }, - { - type: "String", - defaultValue: "", - helpText: - "Key of the error message in i18n bundle. Dafault message key is error-pattern-no-match", - label: "Error message key", - name: "error-message", - }, - ], - }, - { - name: "person-name-prohibited-characters", - description: - "Check if the value is a valid person name as an additional barrier for attacks such as script injection. The validation is based on a default RegEx pattern that blocks characters not common in person names.", - config: [ - { - type: "String", - defaultValue: "", - helpText: - "Key of the error message in i18n bundle. Dafault message key is error-person-name-invalid-character", - label: "Error message key", - name: "error-message", - }, - ], - }, - { - name: "uri", - description: "Check if the value is a valid URI.", - config: [], - }, - { - name: "username-prohibited-characters", - description: - "Check if the value is a valid username as an additional barrier for attacks such as script injection. The validation is based on a default RegEx pattern that blocks characters not common in usernames.", - config: [ - { - type: "String", - defaultValue: "", - helpText: - "Key of the error message in i18n bundle. Dafault message key is error-username-invalid-character", - label: "Error message key", - name: "error-message", - }, - ], - }, -]; diff --git a/apps/admin-ui/src/user/UserAttributes.tsx b/apps/admin-ui/src/user/UserAttributes.tsx index 8b297df4e8..0878c1b77c 100644 --- a/apps/admin-ui/src/user/UserAttributes.tsx +++ b/apps/admin-ui/src/user/UserAttributes.tsx @@ -19,6 +19,7 @@ import { keyValueToArray, } from "../components/key-value-form/key-value-convert"; import { useAdminClient } from "../context/auth/AdminClient"; +import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext"; type UserAttributesProps = { user: UserRepresentation; @@ -30,9 +31,12 @@ export const UserAttributes = ({ user: defaultUser }: UserAttributesProps) => { const { addAlert, addError } = useAlerts(); const [user, setUser] = useState(defaultUser); const form = useForm({ mode: "onChange" }); + const { config } = useUserProfile(); - const convertAttributes = (attr?: Record) => { - return arrayToKeyValue(attr || user.attributes!); + const convertAttributes = () => { + return arrayToKeyValue(user.attributes!).filter( + (a) => !config?.attributes?.some((attribute) => attribute.name === a.key) + ); }; useEffect(() => { @@ -41,7 +45,11 @@ export const UserAttributes = ({ user: defaultUser }: UserAttributesProps) => { const save = async (attributeForm: AttributeForm) => { try { - const attributes = keyValueToArray(attributeForm.attributes!); + const attributes = Object.assign( + {}, + user.attributes || {}, + keyValueToArray(attributeForm.attributes!) + ); await adminClient.users.update({ id: user.id! }, { ...user, attributes }); setUser({ ...user, attributes }); diff --git a/apps/admin-ui/src/user/UserForm.tsx b/apps/admin-ui/src/user/UserForm.tsx index e27a2034a8..8389d4e49b 100644 --- a/apps/admin-ui/src/user/UserForm.tsx +++ b/apps/admin-ui/src/user/UserForm.tsx @@ -29,6 +29,8 @@ import { GroupPickerDialog } from "../components/group/GroupPickerDialog"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type RequiredActionProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation"; import { useAccess } from "../context/access/Access"; +import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; +import { UserProfileFields } from "./UserProfileFields"; export type BruteForced = { isBruteForceProtected?: boolean; @@ -54,6 +56,7 @@ export const UserForm = ({ const { t } = useTranslation("users"); const { realm: realmName } = useRealm(); const formatDate = useFormatDate(); + const isFeatureEnabled = useIsFeatureEnabled(); const [ isRequiredUserActionsDropdownOpen, @@ -190,129 +193,6 @@ export const UserForm = ({ )} - {!realm?.registrationEmailAsUsername && ( - - - - )} - - - - - } - > - ( - onChange(value)} - isChecked={value} - label={t("common:on")} - labelOff={t("common:off")} - aria-label={t("emailVerified")} - /> - )} - /> - - - - - - - - {isBruteForceProtected && ( - - } - > - { - unLockUser(); - setLocked(value); - }} - isChecked={locked} - isDisabled={!locked} - label={t("common:on")} - labelOff={t("common:off")} - aria-label={t("temporaryLocked")} - /> - - )} + {isFeatureEnabled(Feature.DeclarativeUserProfile) && + realm?.attributes?.userProfileEnabled === "true" ? ( + + ) : ( + <> + {!realm?.registrationEmailAsUsername && ( + + + + )} + + + + + } + > + ( + onChange(value)} + isChecked={value} + label={t("common:on")} + labelOff={t("common:off")} + /> + )} + /> + + + + + + + + + )} + {isBruteForceProtected && ( + + } + > + { + unLockUser(); + setLocked(value); + }} + isChecked={locked} + isDisabled={!locked} + label={t("common:on")} + labelOff={t("common:off")} + /> + + )} {!user?.id && ( { + const { t } = useTranslation("realm-settings"); + const { config } = useUserProfile(); + + return ( + ({ + title: g.name || t("general"), + panel: ( +
+ {g.displayDescription && ( + {g.displayDescription} + )} + {config?.attributes?.map((attribute) => ( + + {(attribute.group || "") === g.name && + (attribute.permissions?.view || DEFAULT_ROLES).some((r) => + roles.includes(r) + ) && } + + ))} +
+ ), + }))} + /> + ); +}; + +type FormFieldProps = { + attribute: UserProfileAttribute; + roles: string[]; +}; + +const FormField = ({ attribute, roles }: FormFieldProps) => { + const { t } = useTranslation("users"); + const { errors, register, control } = useFormContext(); + const [open, toggle] = useToggle(); + + const isBundleKey = (displayName?: string) => displayName?.includes("${"); + const unWrap = (key: string) => key.substring(2, key.length - 1); + + const isSelect = (attribute: UserProfileAttribute) => + Object.hasOwn(attribute.validations || {}, "options"); + + const isRootAttribute = (attr?: string) => + attr && ROOT_ATTRIBUTES.includes(attr); + + const isRequired = (required: UserProfileAttributeRequired | undefined) => + Object.keys(required || {}).length !== 0; + + const fieldName = (attribute: UserProfileAttribute) => + `${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`; + + return ( + + {isSelect(attribute) ? ( + ( + + )} + /> + ) : ( + + roles.includes(r) + ) + } + /> + )} + + ); +}; diff --git a/apps/admin-ui/src/user/UsersTabs.tsx b/apps/admin-ui/src/user/UsersTabs.tsx index 90694a1fc7..be3fbc6b3c 100644 --- a/apps/admin-ui/src/user/UsersTabs.tsx +++ b/apps/admin-ui/src/user/UsersTabs.tsx @@ -32,6 +32,7 @@ import { UserGroups } from "./UserGroups"; import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks"; import { UserRoleMapping } from "./UserRoleMapping"; import { UserSessions } from "./UserSessions"; +import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext"; const UsersTabs = () => { const { t } = useTranslation("users"); @@ -85,17 +86,25 @@ const UsersTabs = () => { setAddedGroups(groups); }; - const save = async (user: UserRepresentation) => { - user.username = user.username?.trim(); + const save = async (formUser: UserRepresentation) => { + formUser.username = formUser.username?.trim(); try { if (id) { - await adminClient.users.update({ id }, user); + await adminClient.users.update( + { id }, + { + ...formUser, + attributes: { ...user?.attributes, ...formUser.attributes }, + } + ); addAlert(t("userSaved"), AlertVariant.success); refresh(); } else { - user.groups = addedGroups.map((group) => group.path!); - const createdUser = await adminClient.users.create(user); + const createdUser = await adminClient.users.create({ + ...formUser, + groups: addedGroups.map((group) => group.path!), + }); addAlert(t("userCreated"), AlertVariant.success); navigate(toUser({ id: createdUser.id, realm, tab: "settings" })); @@ -183,88 +192,90 @@ const UsersTabs = () => { )} /> - - {id && user && ( - - {t("common:details")}} - > - - {bruteForced && ( - - )} - - - {t("common:attributes")}} - > - - - {t("common:credentials")}} - > - - - {t("roleMapping")}} - > - - - {t("common:groups")}} - > - - - {t("consents")}} - > - - - {hasAccess("view-identity-providers") && ( + + + {id && user && ( + {t("identityProviderLinks")} - } + eventKey="settings" + data-testid="user-details-tab" + title={{t("common:details")}} > - + + {bruteForced && ( + + )} + - )} - {t("sessions")}} - > - - - - )} - {!id && ( - - - - )} - + {t("common:attributes")}} + > + + + {t("common:credentials")}} + > + + + {t("roleMapping")}} + > + + + {t("common:groups")}} + > + + + {t("consents")}} + > + + + {hasAccess("view-identity-providers") && ( + {t("identityProviderLinks")} + } + > + + + )} + {t("sessions")}} + > + + + + )} + {!id && ( + + + + )} + + );