Added user profile attributes to user detail screen (#3762)
This commit is contained in:
parent
b5698be23c
commit
bce8270e7f
8 changed files with 411 additions and 403 deletions
|
@ -17,13 +17,14 @@ import {
|
||||||
|
|
||||||
import type { IndexedValidations } from "../../NewAttributeSettings";
|
import type { IndexedValidations } from "../../NewAttributeSettings";
|
||||||
import { AddValidatorRoleDialog } from "./AddValidatorRoleDialog";
|
import { AddValidatorRoleDialog } from "./AddValidatorRoleDialog";
|
||||||
import { Validator, validators as allValidator } from "./Validators";
|
|
||||||
import useToggle from "../../../utils/useToggle";
|
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 = {
|
export type AddValidatorDialogProps = {
|
||||||
selectedValidators: IndexedValidations[];
|
selectedValidators: IndexedValidations[];
|
||||||
toggleDialog: () => void;
|
toggleDialog: () => void;
|
||||||
onConfirm: (newValidator: Validator) => void;
|
onConfirm: (newValidator: ComponentTypeRepresentation) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddValidatorDialog = ({
|
export const AddValidatorDialog = ({
|
||||||
|
@ -32,10 +33,13 @@ export const AddValidatorDialog = ({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: AddValidatorDialogProps) => {
|
}: AddValidatorDialogProps) => {
|
||||||
const { t } = useTranslation("realm-settings");
|
const { t } = useTranslation("realm-settings");
|
||||||
const [selectedValidator, setSelectedValidator] = useState<Validator>();
|
const [selectedValidator, setSelectedValidator] =
|
||||||
const [validators, setValidators] = useState(() =>
|
useState<ComponentTypeRepresentation>();
|
||||||
|
const allValidator: ComponentTypeRepresentation[] =
|
||||||
|
useServerInfo().componentTypes?.["org.keycloak.validate.Validator"] || [];
|
||||||
|
const [validators, setValidators] = useState(
|
||||||
allValidator.filter(
|
allValidator.filter(
|
||||||
({ name }) => !selectedValidators.map(({ key }) => key).includes(name)
|
({ id }) => !selectedValidators.map(({ key }) => key).includes(id)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const [addValidatorRoleModalOpen, toggleModal] = useToggle();
|
const [addValidatorRoleModalOpen, toggleModal] = useToggle();
|
||||||
|
@ -47,7 +51,7 @@ export const AddValidatorDialog = ({
|
||||||
onConfirm={(newValidator) => {
|
onConfirm={(newValidator) => {
|
||||||
onConfirm(newValidator);
|
onConfirm(newValidator);
|
||||||
setValidators(
|
setValidators(
|
||||||
validators.filter(({ name }) => name !== newValidator.name)
|
validators.filter(({ id }) => id !== newValidator.id)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
open={addValidatorRoleModalOpen}
|
open={addValidatorRoleModalOpen}
|
||||||
|
@ -70,9 +74,9 @@ export const AddValidatorDialog = ({
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{validators.map((validator) => (
|
{allValidator.map((validator) => (
|
||||||
<Tr
|
<Tr
|
||||||
key={validator.name}
|
key={validator.id}
|
||||||
onRowClick={() => {
|
onRowClick={() => {
|
||||||
setSelectedValidator(validator);
|
setSelectedValidator(validator);
|
||||||
toggleModal();
|
toggleModal();
|
||||||
|
@ -80,10 +84,10 @@ export const AddValidatorDialog = ({
|
||||||
isHoverable
|
isHoverable
|
||||||
>
|
>
|
||||||
<Td dataLabel={t("validatorDialogColNames.colName")}>
|
<Td dataLabel={t("validatorDialogColNames.colName")}>
|
||||||
{validator.name}
|
{validator.id}
|
||||||
</Td>
|
</Td>
|
||||||
<Td dataLabel={t("validatorDialogColNames.colDescription")}>
|
<Td dataLabel={t("validatorDialogColNames.colDescription")}>
|
||||||
{validator.description}
|
{validator.helpText}
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button, Modal, ModalVariant } from "@patternfly/react-core";
|
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
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 { DynamicComponents } from "../../../components/dynamic/DynamicComponents";
|
||||||
import type { Validator } from "./Validators";
|
|
||||||
|
|
||||||
export type AddValidatorRoleDialogProps = {
|
export type AddValidatorRoleDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
toggleDialog: () => void;
|
toggleDialog: () => void;
|
||||||
onConfirm: (newValidator: Validator) => void;
|
onConfirm: (newValidator: ComponentTypeRepresentation) => void;
|
||||||
selected: Validator;
|
selected: ComponentTypeRepresentation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddValidatorRoleDialog = ({
|
export const AddValidatorRoleDialog = ({
|
||||||
|
@ -22,8 +23,8 @@ export const AddValidatorRoleDialog = ({
|
||||||
const { handleSubmit } = form;
|
const { handleSubmit } = form;
|
||||||
const selectedRoleValidator = selected;
|
const selectedRoleValidator = selected;
|
||||||
|
|
||||||
const save = (newValidator: Validator) => {
|
const save = (newValidator: ComponentTypeRepresentation) => {
|
||||||
onConfirm({ ...newValidator, name: selected.name });
|
onConfirm({ ...newValidator, id: selected.id });
|
||||||
toggleDialog();
|
toggleDialog();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -31,9 +32,9 @@ export const AddValidatorRoleDialog = ({
|
||||||
<Modal
|
<Modal
|
||||||
variant={ModalVariant.small}
|
variant={ModalVariant.small}
|
||||||
title={t("addValidatorRole", {
|
title={t("addValidatorRole", {
|
||||||
validatorName: selectedRoleValidator.name,
|
validatorName: selectedRoleValidator.id,
|
||||||
})}
|
})}
|
||||||
description={selectedRoleValidator.description}
|
description={selectedRoleValidator.helpText}
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
onClose={toggleDialog}
|
onClose={toggleDialog}
|
||||||
actions={[
|
actions={[
|
||||||
|
@ -55,9 +56,11 @@ export const AddValidatorRoleDialog = ({
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<FormProvider {...form}>
|
<Form>
|
||||||
<DynamicComponents properties={selectedRoleValidator.config!} />
|
<FormProvider {...form}>
|
||||||
</FormProvider>
|
<DynamicComponents properties={selectedRoleValidator.properties} />
|
||||||
|
</FormProvider>
|
||||||
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -67,7 +67,7 @@ export const AttributeValidations = () => {
|
||||||
onConfirm={(newValidator) => {
|
onConfirm={(newValidator) => {
|
||||||
setValue("validations", [
|
setValue("validations", [
|
||||||
...validators,
|
...validators,
|
||||||
{ key: newValidator.name, value: newValidator.config },
|
{ key: newValidator.id, value: newValidator.properties },
|
||||||
]);
|
]);
|
||||||
}}
|
}}
|
||||||
toggleDialog={toggleModal}
|
toggleDialog={toggleModal}
|
||||||
|
|
|
@ -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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
keyValueToArray,
|
keyValueToArray,
|
||||||
} from "../components/key-value-form/key-value-convert";
|
} from "../components/key-value-form/key-value-convert";
|
||||||
import { useAdminClient } from "../context/auth/AdminClient";
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
|
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
|
||||||
|
|
||||||
type UserAttributesProps = {
|
type UserAttributesProps = {
|
||||||
user: UserRepresentation;
|
user: UserRepresentation;
|
||||||
|
@ -30,9 +31,12 @@ export const UserAttributes = ({ user: defaultUser }: UserAttributesProps) => {
|
||||||
const { addAlert, addError } = useAlerts();
|
const { addAlert, addError } = useAlerts();
|
||||||
const [user, setUser] = useState<UserRepresentation>(defaultUser);
|
const [user, setUser] = useState<UserRepresentation>(defaultUser);
|
||||||
const form = useForm<AttributeForm>({ mode: "onChange" });
|
const form = useForm<AttributeForm>({ mode: "onChange" });
|
||||||
|
const { config } = useUserProfile();
|
||||||
|
|
||||||
const convertAttributes = (attr?: Record<string, any>) => {
|
const convertAttributes = () => {
|
||||||
return arrayToKeyValue(attr || user.attributes!);
|
return arrayToKeyValue(user.attributes!).filter(
|
||||||
|
(a) => !config?.attributes?.some((attribute) => attribute.name === a.key)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -41,7 +45,11 @@ export const UserAttributes = ({ user: defaultUser }: UserAttributesProps) => {
|
||||||
|
|
||||||
const save = async (attributeForm: AttributeForm) => {
|
const save = async (attributeForm: AttributeForm) => {
|
||||||
try {
|
try {
|
||||||
const attributes = keyValueToArray(attributeForm.attributes!);
|
const attributes = Object.assign(
|
||||||
|
{},
|
||||||
|
user.attributes || {},
|
||||||
|
keyValueToArray(attributeForm.attributes!)
|
||||||
|
);
|
||||||
await adminClient.users.update({ id: user.id! }, { ...user, attributes });
|
await adminClient.users.update({ id: user.id! }, { ...user, attributes });
|
||||||
|
|
||||||
setUser({ ...user, attributes });
|
setUser({ ...user, attributes });
|
||||||
|
|
|
@ -29,6 +29,8 @@ import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
|
||||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
import type RequiredActionProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation";
|
import type RequiredActionProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation";
|
||||||
import { useAccess } from "../context/access/Access";
|
import { useAccess } from "../context/access/Access";
|
||||||
|
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
||||||
|
import { UserProfileFields } from "./UserProfileFields";
|
||||||
|
|
||||||
export type BruteForced = {
|
export type BruteForced = {
|
||||||
isBruteForceProtected?: boolean;
|
isBruteForceProtected?: boolean;
|
||||||
|
@ -54,6 +56,7 @@ export const UserForm = ({
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const { realm: realmName } = useRealm();
|
const { realm: realmName } = useRealm();
|
||||||
const formatDate = useFormatDate();
|
const formatDate = useFormatDate();
|
||||||
|
const isFeatureEnabled = useIsFeatureEnabled();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
isRequiredUserActionsDropdownOpen,
|
isRequiredUserActionsDropdownOpen,
|
||||||
|
@ -190,129 +193,6 @@ export const UserForm = ({
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!realm?.registrationEmailAsUsername && (
|
|
||||||
<FormGroup
|
|
||||||
label={t("username")}
|
|
||||||
fieldId="kc-username"
|
|
||||||
isRequired
|
|
||||||
validated={errors.username ? "error" : "default"}
|
|
||||||
helperTextInvalid={t("common:required")}
|
|
||||||
>
|
|
||||||
<KeycloakTextInput
|
|
||||||
ref={register()}
|
|
||||||
type="text"
|
|
||||||
id="kc-username"
|
|
||||||
aria-label={t("username")}
|
|
||||||
name="username"
|
|
||||||
isReadOnly={
|
|
||||||
!!user?.id &&
|
|
||||||
!realm?.editUsernameAllowed &&
|
|
||||||
realm?.editUsernameAllowed !== undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
<FormGroup
|
|
||||||
label={t("email")}
|
|
||||||
fieldId="kc-description"
|
|
||||||
validated={errors.email ? "error" : "default"}
|
|
||||||
helperTextInvalid={t("users:emailInvalid")}
|
|
||||||
>
|
|
||||||
<KeycloakTextInput
|
|
||||||
ref={register({
|
|
||||||
pattern: emailRegexPattern,
|
|
||||||
})}
|
|
||||||
type="email"
|
|
||||||
id="kc-email"
|
|
||||||
name="email"
|
|
||||||
data-testid="email-input"
|
|
||||||
aria-label={t("emailInput")}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup
|
|
||||||
label={t("emailVerified")}
|
|
||||||
fieldId="kc-email-verified"
|
|
||||||
helperTextInvalid={t("common:required")}
|
|
||||||
labelIcon={
|
|
||||||
<HelpItem
|
|
||||||
helpText="users-help:emailVerified"
|
|
||||||
fieldLabelId="users:emailVerified"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
name="emailVerified"
|
|
||||||
defaultValue={false}
|
|
||||||
control={control}
|
|
||||||
render={({ onChange, value }) => (
|
|
||||||
<Switch
|
|
||||||
data-testid="email-verified-switch"
|
|
||||||
id={"kc-user-email-verified"}
|
|
||||||
isDisabled={false}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
isChecked={value}
|
|
||||||
label={t("common:on")}
|
|
||||||
labelOff={t("common:off")}
|
|
||||||
aria-label={t("emailVerified")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup
|
|
||||||
label={t("firstName")}
|
|
||||||
fieldId="kc-firstname"
|
|
||||||
validated={errors.firstName ? "error" : "default"}
|
|
||||||
helperTextInvalid={t("common:required")}
|
|
||||||
>
|
|
||||||
<KeycloakTextInput
|
|
||||||
ref={register()}
|
|
||||||
data-testid="firstName-input"
|
|
||||||
type="text"
|
|
||||||
id="kc-firstname"
|
|
||||||
aria-label={t("firstName")}
|
|
||||||
name="firstName"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup
|
|
||||||
label={t("lastName")}
|
|
||||||
fieldId="kc-name"
|
|
||||||
validated={errors.lastName ? "error" : "default"}
|
|
||||||
>
|
|
||||||
<KeycloakTextInput
|
|
||||||
ref={register()}
|
|
||||||
data-testid="lastName-input"
|
|
||||||
type="text"
|
|
||||||
id="kc-lastname"
|
|
||||||
name="lastName"
|
|
||||||
aria-label={t("lastName")}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
{isBruteForceProtected && (
|
|
||||||
<FormGroup
|
|
||||||
label={t("temporaryLocked")}
|
|
||||||
fieldId="temporaryLocked"
|
|
||||||
labelIcon={
|
|
||||||
<HelpItem
|
|
||||||
helpText="users-help:temporaryLocked"
|
|
||||||
fieldLabelId="users:temporaryLocked"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
data-testid="user-locked-switch"
|
|
||||||
id={"temporaryLocked"}
|
|
||||||
onChange={(value) => {
|
|
||||||
unLockUser();
|
|
||||||
setLocked(value);
|
|
||||||
}}
|
|
||||||
isChecked={locked}
|
|
||||||
isDisabled={!locked}
|
|
||||||
label={t("common:on")}
|
|
||||||
labelOff={t("common:off")}
|
|
||||||
aria-label={t("temporaryLocked")}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={t("requiredUserActions")}
|
label={t("requiredUserActions")}
|
||||||
fieldId="kc-required-user-actions"
|
fieldId="kc-required-user-actions"
|
||||||
|
@ -362,6 +242,129 @@ export const UserForm = ({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
{isFeatureEnabled(Feature.DeclarativeUserProfile) &&
|
||||||
|
realm?.attributes?.userProfileEnabled === "true" ? (
|
||||||
|
<UserProfileFields />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!realm?.registrationEmailAsUsername && (
|
||||||
|
<FormGroup
|
||||||
|
label={t("username")}
|
||||||
|
fieldId="kc-username"
|
||||||
|
isRequired
|
||||||
|
validated={errors.username ? "error" : "default"}
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
>
|
||||||
|
<KeycloakTextInput
|
||||||
|
ref={register()}
|
||||||
|
id="kc-username"
|
||||||
|
aria-label={t("username")}
|
||||||
|
name="username"
|
||||||
|
isReadOnly={
|
||||||
|
!!user?.id &&
|
||||||
|
!realm?.editUsernameAllowed &&
|
||||||
|
realm?.editUsernameAllowed !== undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
<FormGroup
|
||||||
|
label={t("email")}
|
||||||
|
fieldId="kc-description"
|
||||||
|
validated={errors.email ? "error" : "default"}
|
||||||
|
helperTextInvalid={t("users:emailInvalid")}
|
||||||
|
>
|
||||||
|
<KeycloakTextInput
|
||||||
|
ref={register({
|
||||||
|
pattern: emailRegexPattern,
|
||||||
|
})}
|
||||||
|
type="email"
|
||||||
|
id="kc-email"
|
||||||
|
name="email"
|
||||||
|
data-testid="email-input"
|
||||||
|
aria-label={t("emailInput")}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("emailVerified")}
|
||||||
|
fieldId="kc-email-verified"
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="users-help:emailVerified"
|
||||||
|
fieldLabelId="users:emailVerified"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="emailVerified"
|
||||||
|
defaultValue={false}
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Switch
|
||||||
|
data-testid="email-verified-switch"
|
||||||
|
id="kc-user-email-verified"
|
||||||
|
isDisabled={false}
|
||||||
|
onChange={(value) => onChange(value)}
|
||||||
|
isChecked={value}
|
||||||
|
label={t("common:on")}
|
||||||
|
labelOff={t("common:off")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("firstName")}
|
||||||
|
fieldId="kc-firstname"
|
||||||
|
validated={errors.firstName ? "error" : "default"}
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
>
|
||||||
|
<KeycloakTextInput
|
||||||
|
ref={register()}
|
||||||
|
data-testid="firstName-input"
|
||||||
|
id="kc-firstname"
|
||||||
|
name="firstName"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("lastName")}
|
||||||
|
fieldId="kc-name"
|
||||||
|
validated={errors.lastName ? "error" : "default"}
|
||||||
|
>
|
||||||
|
<KeycloakTextInput
|
||||||
|
ref={register()}
|
||||||
|
data-testid="lastName-input"
|
||||||
|
id="kc-lastname"
|
||||||
|
name="lastName"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isBruteForceProtected && (
|
||||||
|
<FormGroup
|
||||||
|
label={t("temporaryLocked")}
|
||||||
|
fieldId="temporaryLocked"
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText="users-help:temporaryLocked"
|
||||||
|
fieldLabelId="users:temporaryLocked"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
data-testid="user-locked-switch"
|
||||||
|
id="temporaryLocked"
|
||||||
|
onChange={(value) => {
|
||||||
|
unLockUser();
|
||||||
|
setLocked(value);
|
||||||
|
}}
|
||||||
|
isChecked={locked}
|
||||||
|
isDisabled={!locked}
|
||||||
|
label={t("common:on")}
|
||||||
|
labelOff={t("common:off")}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
{!user?.id && (
|
{!user?.id && (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label={t("common:groups")}
|
label={t("common:groups")}
|
||||||
|
|
150
apps/admin-ui/src/user/UserProfileFields.tsx
Normal file
150
apps/admin-ui/src/user/UserProfileFields.tsx
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormGroup,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
Text,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
|
import type { UserProfileAttributeRequired } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
|
import { ScrollForm } from "../components/scroll-form/ScrollForm";
|
||||||
|
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
|
||||||
|
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
|
||||||
|
import useToggle from "../utils/useToggle";
|
||||||
|
|
||||||
|
const ROOT_ATTRIBUTES = ["username", "fistName", "lastName", "email"];
|
||||||
|
const DEFAULT_ROLES = ["admin", "user"];
|
||||||
|
|
||||||
|
type UserProfileFieldsProps = {
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserProfileFields = ({
|
||||||
|
roles = ["admin"],
|
||||||
|
}: UserProfileFieldsProps) => {
|
||||||
|
const { t } = useTranslation("realm-settings");
|
||||||
|
const { config } = useUserProfile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollForm
|
||||||
|
sections={[{ name: "" }, ...(config?.groups || [])].map((g) => ({
|
||||||
|
title: g.name || t("general"),
|
||||||
|
panel: (
|
||||||
|
<Form>
|
||||||
|
{g.displayDescription && (
|
||||||
|
<Text className="pf-u-pb-lg">{g.displayDescription}</Text>
|
||||||
|
)}
|
||||||
|
{config?.attributes?.map((attribute) => (
|
||||||
|
<Fragment key={attribute.name}>
|
||||||
|
{(attribute.group || "") === g.name &&
|
||||||
|
(attribute.permissions?.view || DEFAULT_ROLES).some((r) =>
|
||||||
|
roles.includes(r)
|
||||||
|
) && <FormField attribute={attribute} roles={roles} />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<FormGroup
|
||||||
|
key={attribute.name}
|
||||||
|
label={
|
||||||
|
(isBundleKey(attribute.displayName)
|
||||||
|
? t(unWrap(attribute.displayName!))
|
||||||
|
: attribute.displayName) || attribute.name
|
||||||
|
}
|
||||||
|
fieldId={attribute.name}
|
||||||
|
isRequired={isRequired(attribute.required)}
|
||||||
|
validated={errors.username ? "error" : "default"}
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
>
|
||||||
|
{isSelect(attribute) ? (
|
||||||
|
<Controller
|
||||||
|
name={fieldName(attribute)}
|
||||||
|
defaultValue=""
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Select
|
||||||
|
toggleId={attribute.name}
|
||||||
|
onToggle={toggle}
|
||||||
|
onSelect={(_, value) => {
|
||||||
|
onChange(value.toString());
|
||||||
|
toggle();
|
||||||
|
}}
|
||||||
|
selections={value}
|
||||||
|
variant="single"
|
||||||
|
aria-label={t("common:selectOne")}
|
||||||
|
isOpen={open}
|
||||||
|
isDisabled={
|
||||||
|
!(attribute.permissions?.edit || DEFAULT_ROLES).some((r) =>
|
||||||
|
roles.includes(r)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
<SelectOption key="empty" value="">
|
||||||
|
{t("common:choose")}
|
||||||
|
</SelectOption>,
|
||||||
|
...(
|
||||||
|
attribute.validations?.options as { options: string[] }
|
||||||
|
).options.map((option) => (
|
||||||
|
<SelectOption
|
||||||
|
selected={value === option}
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</SelectOption>
|
||||||
|
)),
|
||||||
|
]}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<KeycloakTextInput
|
||||||
|
ref={register()}
|
||||||
|
id={attribute.name}
|
||||||
|
name={fieldName(attribute)}
|
||||||
|
isDisabled={
|
||||||
|
!(attribute.permissions?.edit || DEFAULT_ROLES).some((r) =>
|
||||||
|
roles.includes(r)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
};
|
|
@ -32,6 +32,7 @@ import { UserGroups } from "./UserGroups";
|
||||||
import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks";
|
import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks";
|
||||||
import { UserRoleMapping } from "./UserRoleMapping";
|
import { UserRoleMapping } from "./UserRoleMapping";
|
||||||
import { UserSessions } from "./UserSessions";
|
import { UserSessions } from "./UserSessions";
|
||||||
|
import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext";
|
||||||
|
|
||||||
const UsersTabs = () => {
|
const UsersTabs = () => {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
|
@ -85,17 +86,25 @@ const UsersTabs = () => {
|
||||||
setAddedGroups(groups);
|
setAddedGroups(groups);
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = async (user: UserRepresentation) => {
|
const save = async (formUser: UserRepresentation) => {
|
||||||
user.username = user.username?.trim();
|
formUser.username = formUser.username?.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (id) {
|
if (id) {
|
||||||
await adminClient.users.update({ id }, user);
|
await adminClient.users.update(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
...formUser,
|
||||||
|
attributes: { ...user?.attributes, ...formUser.attributes },
|
||||||
|
}
|
||||||
|
);
|
||||||
addAlert(t("userSaved"), AlertVariant.success);
|
addAlert(t("userSaved"), AlertVariant.success);
|
||||||
refresh();
|
refresh();
|
||||||
} else {
|
} else {
|
||||||
user.groups = addedGroups.map((group) => group.path!);
|
const createdUser = await adminClient.users.create({
|
||||||
const createdUser = await adminClient.users.create(user);
|
...formUser,
|
||||||
|
groups: addedGroups.map((group) => group.path!),
|
||||||
|
});
|
||||||
|
|
||||||
addAlert(t("userCreated"), AlertVariant.success);
|
addAlert(t("userCreated"), AlertVariant.success);
|
||||||
navigate(toUser({ id: createdUser.id, realm, tab: "settings" }));
|
navigate(toUser({ id: createdUser.id, realm, tab: "settings" }));
|
||||||
|
@ -183,88 +192,90 @@ const UsersTabs = () => {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<PageSection variant="light" className="pf-u-p-0">
|
<PageSection variant="light" className="pf-u-p-0">
|
||||||
<FormProvider {...userForm}>
|
<UserProfileProvider>
|
||||||
{id && user && (
|
<FormProvider {...userForm}>
|
||||||
<KeycloakTabs isBox mountOnEnter>
|
{id && user && (
|
||||||
<Tab
|
<KeycloakTabs isBox mountOnEnter>
|
||||||
eventKey="settings"
|
|
||||||
data-testid="user-details-tab"
|
|
||||||
title={<TabTitleText>{t("common:details")}</TabTitleText>}
|
|
||||||
>
|
|
||||||
<PageSection variant="light">
|
|
||||||
{bruteForced && (
|
|
||||||
<UserForm
|
|
||||||
onGroupsUpdate={updateGroups}
|
|
||||||
save={save}
|
|
||||||
user={user}
|
|
||||||
bruteForce={bruteForced}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PageSection>
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
eventKey="attributes"
|
|
||||||
data-testid="attributes"
|
|
||||||
title={<TabTitleText>{t("common:attributes")}</TabTitleText>}
|
|
||||||
>
|
|
||||||
<UserAttributes user={user} />
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
eventKey="credentials"
|
|
||||||
data-testid="credentials"
|
|
||||||
isHidden={!user.access?.manage}
|
|
||||||
title={<TabTitleText>{t("common:credentials")}</TabTitleText>}
|
|
||||||
>
|
|
||||||
<UserCredentials user={user} />
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
eventKey="role-mapping"
|
|
||||||
data-testid="role-mapping-tab"
|
|
||||||
isHidden={!user.access?.mapRoles}
|
|
||||||
title={<TabTitleText>{t("roleMapping")}</TabTitleText>}
|
|
||||||
>
|
|
||||||
<UserRoleMapping id={id} name={user.username!} />
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
eventKey="groups"
|
|
||||||
data-testid="user-groups-tab"
|
|
||||||
title={<TabTitleText>{t("common:groups")}</TabTitleText>}
|
|
||||||
>
|
|
||||||
<UserGroups user={user} />
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
eventKey="consents"
|
|
||||||
data-testid="user-consents-tab"
|
|
||||||
title={<TabTitleText>{t("consents")}</TabTitleText>}
|
|
||||||
>
|
|
||||||
<UserConsents />
|
|
||||||
</Tab>
|
|
||||||
{hasAccess("view-identity-providers") && (
|
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="identity-provider-links"
|
eventKey="settings"
|
||||||
data-testid="identity-provider-links-tab"
|
data-testid="user-details-tab"
|
||||||
title={
|
title={<TabTitleText>{t("common:details")}</TabTitleText>}
|
||||||
<TabTitleText>{t("identityProviderLinks")}</TabTitleText>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<UserIdentityProviderLinks userId={id} />
|
<PageSection variant="light">
|
||||||
|
{bruteForced && (
|
||||||
|
<UserForm
|
||||||
|
onGroupsUpdate={updateGroups}
|
||||||
|
save={save}
|
||||||
|
user={user}
|
||||||
|
bruteForce={bruteForced}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageSection>
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
<Tab
|
||||||
<Tab
|
eventKey="attributes"
|
||||||
eventKey="sessions"
|
data-testid="attributes"
|
||||||
data-testid="user-sessions-tab"
|
title={<TabTitleText>{t("common:attributes")}</TabTitleText>}
|
||||||
title={<TabTitleText>{t("sessions")}</TabTitleText>}
|
>
|
||||||
>
|
<UserAttributes user={user} />
|
||||||
<UserSessions />
|
</Tab>
|
||||||
</Tab>
|
<Tab
|
||||||
</KeycloakTabs>
|
eventKey="credentials"
|
||||||
)}
|
data-testid="credentials"
|
||||||
{!id && (
|
isHidden={!user.access?.manage}
|
||||||
<PageSection variant="light">
|
title={<TabTitleText>{t("common:credentials")}</TabTitleText>}
|
||||||
<UserForm onGroupsUpdate={updateGroups} save={save} />
|
>
|
||||||
</PageSection>
|
<UserCredentials user={user} />
|
||||||
)}
|
</Tab>
|
||||||
</FormProvider>
|
<Tab
|
||||||
|
eventKey="role-mapping"
|
||||||
|
data-testid="role-mapping-tab"
|
||||||
|
isHidden={!user.access?.mapRoles}
|
||||||
|
title={<TabTitleText>{t("roleMapping")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<UserRoleMapping id={id} name={user.username!} />
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey="groups"
|
||||||
|
data-testid="user-groups-tab"
|
||||||
|
title={<TabTitleText>{t("common:groups")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<UserGroups user={user} />
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey="consents"
|
||||||
|
data-testid="user-consents-tab"
|
||||||
|
title={<TabTitleText>{t("consents")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<UserConsents />
|
||||||
|
</Tab>
|
||||||
|
{hasAccess("view-identity-providers") && (
|
||||||
|
<Tab
|
||||||
|
eventKey="identity-provider-links"
|
||||||
|
data-testid="identity-provider-links-tab"
|
||||||
|
title={
|
||||||
|
<TabTitleText>{t("identityProviderLinks")}</TabTitleText>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserIdentityProviderLinks userId={id} />
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
|
<Tab
|
||||||
|
eventKey="sessions"
|
||||||
|
data-testid="user-sessions-tab"
|
||||||
|
title={<TabTitleText>{t("sessions")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<UserSessions />
|
||||||
|
</Tab>
|
||||||
|
</KeycloakTabs>
|
||||||
|
)}
|
||||||
|
{!id && (
|
||||||
|
<PageSection variant="light">
|
||||||
|
<UserForm onGroupsUpdate={updateGroups} save={save} />
|
||||||
|
</PageSection>
|
||||||
|
)}
|
||||||
|
</FormProvider>
|
||||||
|
</UserProfileProvider>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue