Added user profile attributes to user detail screen (#3762)

This commit is contained in:
Erik Jan de Wit 2022-11-17 11:14:47 -05:00 committed by GitHub
parent b5698be23c
commit bce8270e7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 411 additions and 403 deletions

View file

@ -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<Validator>();
const [validators, setValidators] = useState(() =>
const [selectedValidator, setSelectedValidator] =
useState<ComponentTypeRepresentation>();
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 = ({
</Tr>
</Thead>
<Tbody>
{validators.map((validator) => (
{allValidator.map((validator) => (
<Tr
key={validator.name}
key={validator.id}
onRowClick={() => {
setSelectedValidator(validator);
toggleModal();
@ -80,10 +84,10 @@ export const AddValidatorDialog = ({
isHoverable
>
<Td dataLabel={t("validatorDialogColNames.colName")}>
{validator.name}
{validator.id}
</Td>
<Td dataLabel={t("validatorDialogColNames.colDescription")}>
{validator.description}
{validator.helpText}
</Td>
</Tr>
))}

View file

@ -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 = ({
<Modal
variant={ModalVariant.small}
title={t("addValidatorRole", {
validatorName: selectedRoleValidator.name,
validatorName: selectedRoleValidator.id,
})}
description={selectedRoleValidator.description}
description={selectedRoleValidator.helpText}
isOpen={open}
onClose={toggleDialog}
actions={[
@ -55,9 +56,11 @@ export const AddValidatorRoleDialog = ({
</Button>,
]}
>
<FormProvider {...form}>
<DynamicComponents properties={selectedRoleValidator.config!} />
</FormProvider>
<Form>
<FormProvider {...form}>
<DynamicComponents properties={selectedRoleValidator.properties} />
</FormProvider>
</Form>
</Modal>
);
};

View file

@ -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}

View file

@ -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",
},
],
},
];

View file

@ -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<UserRepresentation>(defaultUser);
const form = useForm<AttributeForm>({ mode: "onChange" });
const { config } = useUserProfile();
const convertAttributes = (attr?: Record<string, any>) => {
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 });

View file

@ -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 = ({
</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
label={t("requiredUserActions")}
fieldId="kc-required-user-actions"
@ -362,6 +242,129 @@ export const UserForm = ({
)}
/>
</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 && (
<FormGroup
label={t("common:groups")}

View 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>
);
};

View file

@ -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 = () => {
)}
/>
<PageSection variant="light" className="pf-u-p-0">
<FormProvider {...userForm}>
{id && user && (
<KeycloakTabs isBox mountOnEnter>
<Tab
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") && (
<UserProfileProvider>
<FormProvider {...userForm}>
{id && user && (
<KeycloakTabs isBox mountOnEnter>
<Tab
eventKey="identity-provider-links"
data-testid="identity-provider-links-tab"
title={
<TabTitleText>{t("identityProviderLinks")}</TabTitleText>
}
eventKey="settings"
data-testid="user-details-tab"
title={<TabTitleText>{t("common:details")}</TabTitleText>}
>
<UserIdentityProviderLinks userId={id} />
<PageSection variant="light">
{bruteForced && (
<UserForm
onGroupsUpdate={updateGroups}
save={save}
user={user}
bruteForce={bruteForced}
/>
)}
</PageSection>
</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>
<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
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>
</>
);