Ream-settings-> User profile -> Create attribute (#2265)

* create attribute - wip

* create attribute - wip

* create attribute - added attributes general settings form template

* create attribute - added attributes permission form template

* added validations form

* added validations table

* added empty state for validators table

* added attribute annotations form

* attribute validations - css improvement

* attribute annotations - css improvement

* added requiredWhen label

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute - wip

* new attribute

* fixed deleting attribute

* put the validators into the form

* feedback fixes

* added defaultValues

* fixed permissions

* removed unused form value

Co-authored-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Co-authored-by: Erik Jan de Wit <edewit@redhat.com>
This commit is contained in:
agagancarczyk 2022-03-16 09:32:23 +00:00 committed by GitHub
parent ba4765fdc5
commit 9242ba6935
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1244 additions and 28 deletions

View file

@ -1,23 +1,196 @@
import { PageSection } from "@patternfly/react-core";
import React from "react";
import React, { useState } from "react";
import {
ActionGroup,
AlertVariant,
Button,
Form,
PageSection,
} from "@patternfly/react-core";
import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../components/form-access/FormAccess";
import { Link, useHistory } from "react-router-dom";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { useRealm } from "../context/realm-context/RealmContext";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { AttributeGeneralSettings } from "./user-profile/attribute/AttributeGeneralSettings";
import { AttributePermission } from "./user-profile/attribute/AttributePermission";
import { AttributeValidations } from "./user-profile/attribute/AttributeValidations";
import { toUserProfile } from "./routes/UserProfile";
import "./realm-settings-section.css";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { AttributeAnnotations } from "./user-profile/attribute/AttributeAnnotations";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { UserProfileProvider } from "./user-profile/UserProfileContext";
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type { KeyValueType } from "../components/attribute-form/attribute-convert";
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
export default function NewAttributeSettings() {
type UserProfileAttributeType = UserProfileAttribute &
AttributeRequired &
Permission;
type AttributeRequired = {
roles: string[];
scopeRequired: string[];
enabledWhen: boolean;
requiredWhen: boolean;
};
type Permission = {
view: PermissionView[];
edit: PermissionEdit[];
};
type PermissionView = [
{
adminView: boolean;
userView: boolean;
}
];
type PermissionEdit = [
{
adminEdit: boolean;
userEdit: boolean;
}
];
const CreateAttributeFormContent = ({
save,
}: {
save: (profileConfig: UserProfileConfig) => void;
}) => {
const { t } = useTranslation("realm-settings");
const form = useFormContext();
const { realm } = useRealm();
return (
<PageSection variant="light">
<FormAccess
onSubmit={() => console.log("TODO handle submit")}
isHorizontal
role="view-realm"
className="pf-u-mt-lg"
<UserProfileProvider>
<ScrollForm
sections={[
t("generalSettings"),
t("permission"),
t("validations"),
t("annotations"),
]}
>
<p>{t("createAttribute")}</p>
</FormAccess>
</PageSection>
<AttributeGeneralSettings />
<AttributePermission />
<AttributeValidations />
<AttributeAnnotations />
</ScrollForm>
<Form onSubmit={form.handleSubmit(save)}>
<ActionGroup className="keycloak__form_actions">
<Button
variant="primary"
type="submit"
data-testid="attribute-create"
>
{t("common:create")}
</Button>
<Link
to={toUserProfile({ realm, tab: "attributes" })}
data-testid="attribute-cancel"
className="kc-attributeCancel"
>
{t("common:cancel")}
</Link>
</ActionGroup>
</Form>
</UserProfileProvider>
);
};
export default function NewAttributeSettings() {
const { realm: realmName } = useRealm();
const adminClient = useAdminClient();
const form = useForm<UserProfileConfig>();
const { t } = useTranslation("realm-settings");
const history = useHistory();
const { addAlert, addError } = useAlerts();
const [config, setConfig] = useState<UserProfileConfig | null>(null);
const [clientScopes, setClientScopes] =
useState<ClientScopeRepresentation[]>();
useFetch(
() =>
Promise.all([
adminClient.users.getProfile({ realm: realmName }),
adminClient.clientScopes.find(),
]),
([config, clientScopes]) => {
setConfig(config);
setClientScopes(clientScopes);
},
[]
);
const save = async (profileConfig: UserProfileAttributeType) => {
const scopeNames = clientScopes?.map((clientScope) => clientScope.name);
const selector = {
scopes: profileConfig.enabledWhen
? scopeNames
: profileConfig.selector?.scopes,
};
const required = {
roles: profileConfig.roles,
scopes: profileConfig.requiredWhen
? scopeNames
: profileConfig.scopeRequired,
};
const validations = profileConfig.validations;
const annotations = (profileConfig.annotations! as KeyValueType[]).reduce(
(obj, item) => Object.assign(obj, { [item.key]: item.value }),
{}
);
const newAttribute = [
{
name: profileConfig.name,
displayName: profileConfig.displayName,
required,
validations,
selector,
permissions: profileConfig.permissions,
annotations,
},
];
const newAttributesList = config?.attributes!.concat(
newAttribute as UserProfileAttribute
);
try {
await adminClient.users.updateProfile({
attributes: newAttributesList,
realm: realmName,
});
history.push(toUserProfile({ realm: realmName, tab: "attributes" }));
addAlert(
t("realm-settings:createAttributeSuccess"),
AlertVariant.success
);
} catch (error) {
addError("realm-settings:createAttributeError", error);
}
};
return (
<FormProvider {...form}>
<ViewHeader
titleKey={t("createAttribute")}
subKey={t("createAttributeSubTitle")}
/>
<PageSection variant="light">
<CreateAttributeFormContent save={() => form.handleSubmit(save)()} />
</PageSection>
</FormProvider>
);
}

View file

@ -146,6 +146,20 @@ export default {
"The condition is checked during client registration/update requests and it evaluates to true if the entity (usually user), who is creating/updating client is member of the specified role. For reference the realm role, you can use the realm role name like 'my_realm_role' . For reference client role, you can use the client_id.role_name for example 'my_client.my_client_role' will refer to client role 'my_client_role' of client 'my_client'. ",
defaultGroups:
"Default groups allow you to automatically assign groups membership whenever any new user is created or imported through <1>identity brokering</1>.",
attributeGeneralSettingsDescription:
"This section contains a few basic settings common to all attributes.",
attributeNameHelp: "The name of the attribute.",
attributeDisplayNameHelp:
"Display name for the attribute. Supports keys for localized values as well. For example: ${profile.attribute.phoneNumber}.",
attributeGroupHelp: "user.profile.attribute.group.tooltip",
requiredHelp:
"Set the attribute as required. If enabled, the attribute must be set by users and administrators. Otherwise, the attribute is optional.",
attributePermissionDescription:
"This section contains permissions for who can edit and who can view the attribute.",
whoCanEditHelp:
"If enabled, users or administrators can view and edit the attribute. Otherwise, users or administrators don't have access to write to the attribute.",
whoCanViewHelp:
"If enabled, users or administrators can view the attribute. Otherwise, users or administrators don't have access to the attribute.",
editUsername:
"If enabled, the username field is editable, readonly otherwise.",
},

View file

@ -376,15 +376,51 @@ export default {
attributeName: "Name",
attributeDisplayName: "Display name",
attributeGroup: "Attribute group",
enabledWhen: "Enabled when",
required: "Required",
requiredFor: "Required for",
requiredWhen: "Required when",
whoCanEdit: "Who can edit?",
whoCanView: "Who can view?",
user: "User",
admin: "Admin",
addValidator: "Add validator",
addValidatorRole: "Add {{validatorName}} validator",
validatorDialogColNames: {
colName: "Role name",
colDescription: "Description",
},
validatorColNames: {
colName: "Validator name",
colConfig: "Config",
},
deleteValidatorConfirmTitle: "Delete validator?",
deleteValidatorConfirmMsg:
"Are you sure you want to permanently delete the validator {{validatorName}}?",
validatorDeletedSuccess:
"Success! User Profile configuration has been saved.",
validatorDeletedError: "Error saving User Profile: {{error}}",
emptyValidators: "No validators.",
updatedUserProfileSuccess: "User Profile configuration has been saved",
updatedUserProfileError: "User Profile configuration hasn't been saved",
createAttribute: "Create attribute",
createAttributeSubTitle: "Create a new attribute",
createAttributeSuccess:
"Success! User Profile configuration has been saved.",
createAttributeError:
"Error! User Profile configuration has not been saved {{error}}.",
attributesDropdown: "Attributes dropdown",
deleteAttributeConfirmTitle: "Delete attribute?",
deleteAttributeConfirm:
"Are you sure you want to permanently delete the attribute {{attributeName}}?",
deleteAttributeSuccess: "Attribute deleted",
deleteAttributeError: "",
deleteAttributeError: "Attribute not deleted",
generalSettings: "General settings",
permission: "Permission",
validations: "Validations",
annotations: "Annotations",
addAnnotationText: "Add annotation",
validateName: "Attribute configuration without name is not allowed.",
eventType: "Event saved type",
searchEventType: "Search saved event type",
addSavedTypes: "Add saved types",

View file

@ -273,3 +273,25 @@ input#kc-scopes {
.kc-join-group-modal-check {
margin-right: var(--pf-global--spacer--sm);
}
.kc-requiredFor {
display: flex;
}
.kc-requiredFor-option {
margin-right: 20px;
}
.kc-emptyValidators {
color: #8d9195;
margin-left: 25px;
}
.kc-attributes-validations {
max-width: 1024px;
margin-bottom: 52px;
}
.kc-attributeCancel {
align-self: center;
}

View file

@ -13,6 +13,7 @@ import { EditClientPolicyConditionRoute } from "./routes/EditCondition";
import { UserProfileRoute } from "./routes/UserProfile";
import { AddAttributeRoute } from "./routes/AddAttribute";
import { KeysRoute } from "./routes/KeysTab";
import { AttributeRoute } from "./routes/Attribute";
import { NewAttributesGroupRoute } from "./routes/NewAttributesGroup";
import { EditAttributesGroupRoute } from "./routes/EditAttributesGroup";
@ -31,6 +32,7 @@ const routes: RouteDef[] = [
EditClientPolicyConditionRoute,
UserProfileRoute,
AddAttributeRoute,
AttributeRoute,
NewAttributesGroupRoute,
EditAttributesGroupRoute,
];

View file

@ -10,8 +10,8 @@ export type AddAttributeParams = {
export const AddAttributeRoute: RouteDef = {
path: "/:realm/realm-settings/userProfile/attributes/add-attribute",
component: lazy(() => import("../NewAttributeSettings")),
breadcrumb: (t) => t("realmSettings"),
access: "view-realm",
breadcrumb: (t) => t("realm-settings:createAttribute"),
access: "manage-realm",
};
export const toAddAttribute = (

View file

@ -0,0 +1,22 @@
import type { LocationDescriptorObject } from "history";
import { lazy } from "react";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
export type AttributeParams = {
realm: string;
attributeName: string;
};
export const AttributeRoute: RouteDef = {
path: "/:realm/realm-settings/userProfile/attributes/:attributeName/edit-attribute",
component: lazy(() => import("../NewAttributeSettings")),
breadcrumb: (t) => t("realm-settings:createAttribute"),
access: "manage-realm",
};
export const toAttribute = (
params: AttributeParams
): LocationDescriptorObject => ({
pathname: generatePath(AttributeRoute.path, params),
});

View file

@ -13,7 +13,6 @@ import {
} from "@patternfly/react-core";
import { FilterIcon } from "@patternfly/react-icons";
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
import { DraggableTable } from "../../authentication/components/DraggableTable";
import { Link, useHistory } from "react-router-dom";
@ -21,6 +20,8 @@ import { toAddAttribute } from "../routes/AddAttribute";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useUserProfile } from "./UserProfileContext";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { toAttribute } from "../routes/Attribute";
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import useToggle from "../../utils/useToggle";
type movedAttributeType = UserProfileAttribute;
@ -34,8 +35,7 @@ export const AttributesTab = () => {
const [isFilterTypeDropdownOpen, toggleIsFilterTypeDropdownOpen] =
useToggle();
const [data, setData] = useState(config?.attributes);
const [attributeToDelete, setAttributeToDelete] =
useState<{ name: string }>();
const [attributeToDelete, setAttributeToDelete] = useState("");
const executeMove = async (
attribute: UserProfileAttribute,
@ -62,13 +62,13 @@ export const AttributesTab = () => {
const goToCreate = () => history.push(toAddAttribute({ realm: realmName }));
const updatedAttributes = config?.attributes!.filter(
(attribute) => attribute.name !== attributeToDelete?.name
(attribute) => attribute.name !== attributeToDelete
);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("deleteAttributeConfirmTitle"),
messageKey: t("deleteAttributeConfirm", {
attributeName: attributeToDelete?.name!,
attributeName: attributeToDelete,
}),
continueButtonLabel: t("common:delete"),
continueButtonVariant: ButtonVariant.danger,
@ -80,12 +80,22 @@ export const AttributesTab = () => {
errorMessageKey: "realm-settings:deleteAttributeError",
}
);
setAttributeToDelete({
name: "",
});
setAttributeToDelete("");
},
});
const cellFormatter = (row: UserProfileAttribute) => (
<Link
to={toAttribute({
realm: realmName,
attributeName: row.name!,
})}
key={row.name}
>
{row.name}
</Link>
);
if (!config?.attributes) {
return <KeycloakSpinner />;
}
@ -177,9 +187,7 @@ export const AttributesTab = () => {
{
name: "name",
displayKey: t("attributeName"),
cellRenderer: (row) => (
<Link to={toAddAttribute({ realm: realmName })}>{row.name}</Link>
),
cellRenderer: cellFormatter,
},
{
name: "displayName",

View file

@ -0,0 +1,75 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalVariant } from "@patternfly/react-core";
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@patternfly/react-table";
import { AddValidatorRoleDialog } from "./AddValidatorRoleDialog";
import { Validator, validators } from "./Validators";
import useToggle from "../../../utils/useToggle";
export type AddValidatorDialogProps = {
toggleDialog: () => void;
onConfirm: (newValidator: Validator) => void;
};
export const AddValidatorDialog = ({
toggleDialog,
onConfirm,
}: AddValidatorDialogProps) => {
const { t } = useTranslation("realm-settings");
const [selectedValidator, setSelectedValidator] = useState<Validator>();
const [addValidatorRoleModalOpen, toggleModal] = useToggle();
return (
<>
{addValidatorRoleModalOpen && (
<AddValidatorRoleDialog
onConfirm={(newValidator) => onConfirm(newValidator)}
open={addValidatorRoleModalOpen}
toggleDialog={toggleModal}
selected={selectedValidator!}
/>
)}
<Modal
variant={ModalVariant.small}
title={t("addValidator")}
isOpen
onClose={toggleDialog}
>
<TableComposable aria-label="validators-table">
<Thead>
<Tr>
<Th>{t("validatorDialogColNames.colName")}</Th>
<Th>{t("validatorDialogColNames.colDescription")}</Th>
</Tr>
</Thead>
<Tbody>
{validators.map((validator) => (
<Tr
key={validator.name}
onRowClick={() => {
setSelectedValidator(validator);
toggleModal();
}}
isHoverable
>
<Td dataLabel={t("validatorDialogColNames.colName")}>
{validator.name}
</Td>
<Td dataLabel={t("validatorDialogColNames.colDescription")}>
{validator.description}
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
</Modal>
</>
);
};

View file

@ -0,0 +1,67 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, ModalVariant } from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form";
import { DynamicComponents } from "../../../components/dynamic/DynamicComponents";
import type { Validator } from "./Validators";
export type AddValidatorRoleDialogProps = {
open: boolean;
toggleDialog: () => void;
onConfirm: (newValidator: Validator) => void;
selected: Validator;
};
export const AddValidatorRoleDialog = ({
open,
toggleDialog,
onConfirm,
selected,
}: AddValidatorRoleDialogProps) => {
const { t } = useTranslation("realm-settings");
const form = useForm();
const { handleSubmit } = form;
const selectedRoleValidator = selected;
const save = () => {
const formValues = form.getValues();
formValues.name = selectedRoleValidator.name;
const newValidator = {
name: formValues.name,
config: formValues.config ?? [],
};
onConfirm(newValidator);
toggleDialog();
};
return (
<Modal
variant={ModalVariant.small}
title={t("addValidatorRole", {
validatorName: selectedRoleValidator.name,
})}
description={selectedRoleValidator.description}
isOpen={open}
onClose={toggleDialog}
actions={[
<Button
key="save"
data-testid="save-validator-role-button"
variant="primary"
onClick={() => handleSubmit(save)()}
>
{t("common:save")}
</Button>,
<Button key="cancel" variant="link" onClick={toggleDialog}>
{t("common:cancel")}
</Button>,
]}
>
<FormProvider {...form}>
<DynamicComponents properties={selectedRoleValidator.config!} />
</FormProvider>
</Modal>
);
};

View file

@ -0,0 +1,46 @@
import React from "react";
import { FormGroup, Grid, GridItem } from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../../../components/form-access/FormAccess";
import "../../realm-settings-section.css";
import { FormProvider, useFormContext } from "react-hook-form";
import {
AttributeInput,
AttributeType,
} from "../../../components/attribute-input/AttributeInput";
export type AttributeAnnotationsProps = {
isKeySelectable?: boolean;
selectableValues?: AttributeType[];
};
export const AttributeAnnotations = ({
isKeySelectable,
selectableValues,
}: AttributeAnnotationsProps) => {
const { t } = useTranslation("realm-settings");
const form = useFormContext();
return (
<FormAccess role="manage-realm" isHorizontal>
<FormGroup
hasNoPaddingTop
label={t("annotations")}
fieldId="kc-annotations"
className="kc-annotations-label"
>
<Grid className="kc-annotations">
<GridItem>
<FormProvider {...form}>
<AttributeInput
isKeySelectable={isKeySelectable}
selectableValues={selectableValues}
name="annotations"
/>
</FormProvider>
</GridItem>
</Grid>
</FormGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,350 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable prettier/prettier */
import React, { useState } from "react";
import {
Divider,
FormGroup,
Radio,
Select,
SelectOption,
SelectVariant,
Switch,
TextInput,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { HelpItem } from "../../../components/help-enabler/HelpItem";
import { Controller, useFormContext } from "react-hook-form";
import { FormAccess } from "../../../components/form-access/FormAccess";
import { useAdminClient, useFetch } from "../../../context/auth/AdminClient";
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
import "../../realm-settings-section.css";
const ENABLED_REQUIRED_WHEN = ["Always", "Scopes are requested"] as const;
const REQUIRED_FOR = [
{ label: "Both users and admins", value: ["admin", "user"]}, { label: "Only users", value: "user" },{ label: "Only admins", value: "admin" }
] as const;
export const AttributeGeneralSettings = () => {
const { t } = useTranslation("realm-settings");
const adminClient = useAdminClient();
const form = useFormContext();
const [clientScopes, setClientScopes] =
useState<ClientScopeRepresentation[]>();
const [selectEnabledWhenOpen, setSelectEnabledWhenOpen] = useState(false);
const [selectRequiredForOpen, setSelectRequiredForOpen] = useState(false);
const [isAttributeGroupDropdownOpen, setIsAttributeGroupDropdownOpen] =
useState(false);
const [enabledWhenSelection, setEnabledWhenSelection] = useState("Always");
const [requiredWhenSelection, setRequiredWhenSelection] = useState("Always");
useFetch(
() => adminClient.clientScopes.find(),
(clientScopes) => {
setClientScopes(clientScopes);
},
[]
);
return (
<FormAccess role="manage-realm" isHorizontal>
<FormGroup
label={t("attributeName")}
labelIcon={
<HelpItem
helpText="realm-settings-help:attributeNameHelp"
fieldLabelId="realm-settings:attributeName"
/>
}
fieldId="kc-attribute-name"
isRequired
>
<TextInput
isRequired
type="text"
id="kc-attribute-name"
name="name"
defaultValue=""
ref={form.register({
required: {
value: true,
message: `${t("validateName")}`,
},
})}
data-testid="attribute-name"
/>
{form.errors.name && (
<div className="error">{form.errors.name.message}</div>
)}
</FormGroup>
<FormGroup
label={t("attributeDisplayName")}
labelIcon={
<HelpItem
helpText="realm-settings-help:attributeDisplayNameHelp"
fieldLabelId="realm-settings:attributeDisplayName"
/>
}
fieldId="kc-attribute-display-name"
>
<TextInput
type="text"
id="kc-attribute-display-name"
name="displayName"
defaultValue=""
ref={form.register}
data-testid="attribute-display-name"
/>
</FormGroup>
<FormGroup
label={t("attributeGroup")}
labelIcon={
<HelpItem
helpText="realm-setting-help:attributeGroupHelp"
fieldLabelId="realm-setting:attributeGroup"
/>
}
fieldId="kc-attribute-group"
>
<Controller
name="attributeGroup"
defaultValue=""
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-attributeGroup"
onToggle={() =>
setIsAttributeGroupDropdownOpen(!isAttributeGroupDropdownOpen)
}
isOpen={isAttributeGroupDropdownOpen}
onSelect={(_, value) => {
onChange(value.toString());
setIsAttributeGroupDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
<SelectOption key={0} value="" isPlaceholder>
Select a group
</SelectOption>
<SelectOption key={1} value=""></SelectOption>
</Select>
)}
></Controller>
</FormGroup>
<Divider />
<FormGroup label={t("enabledWhen")} fieldId="enabledWhen" hasNoPaddingTop>
<Controller
name="enabledWhen"
data-testid="enabledWhen"
control={form.control}
defaultValue={ENABLED_REQUIRED_WHEN[0]}
render={({ onChange, value }) => (
<>
{ENABLED_REQUIRED_WHEN.map((option) => (
<Radio
id={option}
key={option}
data-testid={option}
isChecked={value === option}
name="enabledWhen"
onChange={() => {
onChange(option);
setEnabledWhenSelection(option);
}}
label={option}
className="pf-u-mb-md"
/>
))}
</>
)}
/>
</FormGroup>
<FormGroup fieldId="kc-scope-enabled-when">
<Controller
name="scopes"
control={form.control}
defaultValue={[]}
render={({
onChange,
value,
}: {
onChange: (newValue: string[]) => void;
value: string[];
}) => (
<Select
name="scopes"
data-testid="enabled-when-scope-field"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
chipGroupProps={{
numChips: 3,
expandedText: t("common:hide"),
collapsedText: t("common:showRemaining"),
}}
onToggle={(isOpen) => setSelectEnabledWhenOpen(isOpen)}
selections={value}
onSelect={(_, selectedValue) => {
const option = selectedValue.toString();
let changedValue = [""];
if (value) {
changedValue = value.includes(option)
? value.filter((item) => item !== option)
: [...value, option];
} else {
changedValue = [option];
}
onChange(changedValue);
}}
onClear={(selectedValues) => {
selectedValues.stopPropagation();
onChange([]);
}}
isOpen={selectEnabledWhenOpen}
isDisabled={enabledWhenSelection === "Always"}
aria-labelledby={"scope"}
>
{clientScopes?.map((option) => (
<SelectOption key={option.name} value={option.name} />
))}
</Select>
)}
/>
</FormGroup>
<Divider />
<FormGroup
label={t("required")}
labelIcon={
<HelpItem
helpText="realm-settings-help:requiredHelp"
fieldLabelId="realm-settings:required"
/>
}
fieldId="kc-required"
hasNoPaddingTop
>
<Controller
name="required"
defaultValue={["false"]}
control={form.control}
render={({ onChange, value }) => (
<Switch
id={"kc-required"}
isDisabled={false}
onChange={(value) => onChange([`${value}`])}
isChecked={value[0] === "true"}
label={t("common:on")}
labelOff={t("common:off")}
/>
)}
></Controller>
</FormGroup>
<FormGroup label={t("requiredFor")} fieldId="requiredFor" hasNoPaddingTop>
<Controller
name="roles"
data-testid="requiredFor"
defaultValue={REQUIRED_FOR[0].value}
control={form.control}
render={({ onChange, value }) => (
<div className="kc-requiredFor">
{REQUIRED_FOR.map((option) => (
<Radio
id={option.label}
key={option.label}
data-testid={option}
isChecked={value === option.value}
name="roles"
onChange={() => onChange(Array.isArray(option.value) ? option.value : [option.value])}
label={option.label}
className="kc-requiredFor-option"
/>
))}
</div>
)}
/>
</FormGroup>
<FormGroup
label={t("requiredWhen")}
fieldId="requiredWhen"
hasNoPaddingTop
>
<Controller
name="requiredWhen"
data-testid="requiredWhen"
defaultValue={ENABLED_REQUIRED_WHEN[0]}
control={form.control}
render={({ onChange, value }) => (
<>
{ENABLED_REQUIRED_WHEN.map((option) => (
<Radio
id={option}
key={option}
data-testid={option}
isChecked={value === option}
name="requiredWhen"
onChange={() => {
onChange(option);
setRequiredWhenSelection(option);
}}
label={option}
className="pf-u-mb-md"
/>
))}
</>
)}
/>
</FormGroup>
<FormGroup fieldId="kc-scope-required-when">
<Controller
name="scopeRequired"
control={form.control}
defaultValue={[]}
render={({
onChange,
value,
}: {
onChange: (newValue: string[]) => void;
value: string[];
}) => (
<Select
name="scopeRequired"
data-testid="required-when-scope-field"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
chipGroupProps={{
numChips: 3,
expandedText: t("common:hide"),
collapsedText: t("common:showRemaining"),
}}
onToggle={(isOpen) => setSelectRequiredForOpen(isOpen)}
selections={value}
onSelect={(_, selectedValue) => {
const option = selectedValue.toString();
let changedValue = [""];
if (value) {
changedValue = value.includes(option)
? value.filter((item) => item !== option)
: [...value, option];
} else {
changedValue = [option];
}
onChange(changedValue);
}}
onClear={(selectedValues) => {
selectedValues.stopPropagation();
onChange([]);
}}
isOpen={selectRequiredForOpen}
isDisabled={requiredWhenSelection === "Always"}
aria-labelledby={"scope"}
>
{clientScopes?.map((option) => (
<SelectOption key={option.name} value={option.name} />
))}
</Select>
)}
/>
</FormGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,100 @@
import React from "react";
import { Checkbox, FormGroup, Grid, GridItem } from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { HelpItem } from "../../../components/help-enabler/HelpItem";
import { Controller, useFormContext } from "react-hook-form";
import { FormAccess } from "../../../components/form-access/FormAccess";
import "../../realm-settings-section.css";
const Permissions = ({ name }: { name: string }) => {
const { t } = useTranslation("realm-settings");
const { control } = useFormContext();
return (
<Grid>
<GridItem lg={4} sm={6}>
<Controller
name={`permissions.${name}`}
control={control}
defaultValue={[]}
render={({ onChange, value }) => (
<Checkbox
id={`user-${name}`}
label={t("user")}
value="user"
data-testid={`user-${name}`}
isChecked={value.includes("user")}
onChange={() => {
const option = "user";
const changedValue = value.includes(option)
? value.filter((item: string) => item !== option)
: [option];
onChange(changedValue);
}}
isDisabled={value.includes("admin")}
/>
)}
/>
</GridItem>
<GridItem lg={8} sm={6}>
<Controller
name={`permissions.${name}`}
control={control}
defaultValue={[]}
render={({ onChange, value }) => (
<Checkbox
id={`admin-${name}`}
label={t("admin")}
value="admin"
data-testid={`admin-${name}`}
isChecked={value.includes("admin")}
onChange={() => {
const option = "admin";
const changedValue = value.includes(option)
? value.filter((item: string) => item !== option)
: ["user", option];
onChange(changedValue);
}}
/>
)}
/>
</GridItem>
</Grid>
);
};
export const AttributePermission = () => {
const { t } = useTranslation("realm-settings");
return (
<FormAccess role="manage-realm" isHorizontal>
<FormGroup
hasNoPaddingTop
label={t("whoCanEdit")}
labelIcon={
<HelpItem
helpText="realm-settings-help:whoCanEditHelp"
fieldLabelId="realm-settings:whoCanEdit"
/>
}
fieldId="kc-who-can-edit"
>
<Permissions name="edit" />
</FormGroup>
<FormGroup
hasNoPaddingTop
label={t("whoCanView")}
labelIcon={
<HelpItem
helpText="realm-settings-help:whoCanViewHelp"
fieldLabelId="realm-settings:whoCanView"
/>
}
fieldId="kc-who-can-view"
>
<Permissions name="view" />
</FormGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import React, { useEffect, useState } from "react";
import {
Button,
ButtonVariant,
Divider,
Flex,
FlexItem,
Text,
TextVariants,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import "../../realm-settings-section.css";
import { PlusCircleIcon } from "@patternfly/react-icons";
import { AddValidatorDialog } from "../attribute/AddValidatorDialog";
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@patternfly/react-table";
import { useConfirmDialog } from "../../../components/confirm-dialog/ConfirmDialog";
import type { Validator } from "./Validators";
import useToggle from "../../../utils/useToggle";
import { useFormContext, useWatch } from "react-hook-form";
import "../../realm-settings-section.css";
export const AttributeValidations = () => {
const { t } = useTranslation("realm-settings");
const [addValidatorModalOpen, toggleModal] = useToggle();
const [validatorToDelete, setValidatorToDelete] =
useState<{ name: string }>();
const { setValue, control, register } = useFormContext();
const validators = useWatch<Validator[]>({
name: "validations",
control,
defaultValue: [],
});
useEffect(() => register("validations"), []);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("deleteValidatorConfirmTitle"),
messageKey: t("deleteValidatorConfirmMsg", {
validatorName: validatorToDelete?.name!,
}),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
console.log("TODO");
},
});
return (
<>
{addValidatorModalOpen && (
<AddValidatorDialog
onConfirm={(newValidator) => {
setValue("validations", [...validators, newValidator]);
}}
toggleDialog={toggleModal}
/>
)}
<DeleteConfirm />
<div className="kc-attributes-validations">
<Flex>
<FlexItem align={{ default: "alignRight" }}>
<Button
id="addValidator"
onClick={() => toggleModal()}
variant="link"
className="kc-addValidator"
data-testid="addValidator"
icon={<PlusCircleIcon />}
>
{t("realm-settings:addValidator")}
</Button>
</FlexItem>
</Flex>
<Divider />
<TableComposable aria-label="validators-table">
<Thead>
<Tr>
<Th>{t("validatorColNames.colName")}</Th>
<Th>{t("validatorColNames.colConfig")}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{validators.length ? (
validators.map((validator: Validator) => (
<Tr key={validator.name}>
<Td dataLabel={t("validatorColNames.colName")}>
{validator.name}
</Td>
<Td dataLabel={t("validatorColNames.colConfig")}>
{JSON.stringify(validator.config)}
</Td>
<Td>
<Button
key="validator"
variant="link"
data-testid="deleteValidator"
isDisabled={true}
onClick={() => {
toggleDeleteDialog();
setValidatorToDelete({
name: validator.name,
});
}}
>
{t("common:delete")}
</Button>
</Td>
</Tr>
))
) : (
<Text className="kc-emptyValidators" component={TextVariants.h6}>
{t("realm-settings:emptyValidators")}
</Text>
)}
</Tbody>
</TableComposable>
</div>
</>
);
};

View file

@ -0,0 +1,171 @@
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",
},
],
},
];