Adds selectable attribute functionality (#1840)
* index on authEvaluateTab: f0a2494d
Add nexus profile for releases (#1704)
auth evaluate wip
wip auth evaluate tab
add identity information and permissions
help text and wip evaluate
* wip contextual attributes
* wip contextual attributes
* add conditional dropdown for auth method
* key and value can be saved now
* wip selectable save
* selectable attribute input save success
* changes to AttributeInput only
* revert files to versions from master
* add back defaultContextAttributes
* set id
* delete autheval file
* remove isKeySelectable
* lift props
* localize names and separate dropdown from the other attribute inputs
* remove log stmt
* add placeholder, return implicitly
* fix select/save
* reorder hook and conditional
* translate, fields/save working
This commit is contained in:
parent
dcfb84e7d1
commit
105d0dffb0
7 changed files with 254 additions and 26 deletions
|
@ -37,6 +37,31 @@ export default {
|
||||||
searchByName: "Search by name",
|
searchByName: "Search by name",
|
||||||
setup: "Setup",
|
setup: "Setup",
|
||||||
evaluate: "Evaluate",
|
evaluate: "Evaluate",
|
||||||
|
selectOrTypeAKey: "Select or type a key",
|
||||||
|
custom: "Custom Attribute...",
|
||||||
|
kc: {
|
||||||
|
identity: {
|
||||||
|
authc: {
|
||||||
|
method: "Authentication Method",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
realm: {
|
||||||
|
name: "Realm",
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
date_time: "Date/Time (MM/dd/yyyy hh:mm:ss)",
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
network: {
|
||||||
|
ip_address: "Client IPv4 Address",
|
||||||
|
host: "Client Host",
|
||||||
|
},
|
||||||
|
user_agent: "Client/User Agent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
password: "Password",
|
||||||
|
oneTimePassword: "One-Time Password",
|
||||||
|
kerberos: "Kerberos",
|
||||||
assignRole: "Assign role",
|
assignRole: "Assign role",
|
||||||
unAssignRole: "Unassign",
|
unAssignRole: "Unassign",
|
||||||
removeMappingTitle: "Remove mapping?",
|
removeMappingTitle: "Remove mapping?",
|
||||||
|
|
|
@ -19,3 +19,49 @@ export const getProtocolName = (t: TFunction<"clients">, protocol: string) => {
|
||||||
|
|
||||||
return protocol;
|
return protocol;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const defaultContextAttributes = [
|
||||||
|
{
|
||||||
|
key: "custom",
|
||||||
|
name: "Custom Attribute...",
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kc.identity.authc.method",
|
||||||
|
name: "Authentication Method",
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
key: "pwd",
|
||||||
|
name: "Password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "otp",
|
||||||
|
name: "One-Time Password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kbr",
|
||||||
|
name: "Kerberos",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kc.realm.name",
|
||||||
|
name: "Realm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kc.time.date_time",
|
||||||
|
name: "Date/Time (MM/dd/yyyy hh:mm:ss)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kc.client.network.ip_address",
|
||||||
|
name: "Client IPv4 Address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kc.client.network.host",
|
||||||
|
name: "Client Host",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kc.client.user_agent",
|
||||||
|
name: "Client/User Agent",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
|
@ -14,11 +14,19 @@ export type AttributeForm = Omit<RoleRepresentation, "attributes"> & {
|
||||||
|
|
||||||
export type AttributesFormProps = {
|
export type AttributesFormProps = {
|
||||||
form: UseFormMethods<AttributeForm>;
|
form: UseFormMethods<AttributeForm>;
|
||||||
|
isKeySelectable?: boolean;
|
||||||
|
selectableValues?: string[];
|
||||||
save?: (model: AttributeForm) => void;
|
save?: (model: AttributeForm) => void;
|
||||||
reset?: () => void;
|
reset?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AttributesForm = ({ form, reset, save }: AttributesFormProps) => {
|
export const AttributesForm = ({
|
||||||
|
form,
|
||||||
|
reset,
|
||||||
|
save,
|
||||||
|
isKeySelectable,
|
||||||
|
selectableValues,
|
||||||
|
}: AttributesFormProps) => {
|
||||||
const { t } = useTranslation("roles");
|
const { t } = useTranslation("roles");
|
||||||
const noSaveCancelButtons = !save && !reset;
|
const noSaveCancelButtons = !save && !reset;
|
||||||
const {
|
const {
|
||||||
|
@ -32,7 +40,11 @@ export const AttributesForm = ({ form, reset, save }: AttributesFormProps) => {
|
||||||
onSubmit={save ? handleSubmit(save) : undefined}
|
onSubmit={save ? handleSubmit(save) : undefined}
|
||||||
>
|
>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<AttributeInput name="attributes" />
|
<AttributeInput
|
||||||
|
isKeySelectable={isKeySelectable}
|
||||||
|
selectableValues={selectableValues}
|
||||||
|
name="attributes"
|
||||||
|
/>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
{!noSaveCancelButtons && (
|
{!noSaveCancelButtons && (
|
||||||
<ActionGroup className="kc-attributes__action-group">
|
<ActionGroup className="kc-attributes__action-group">
|
||||||
|
|
|
@ -21,3 +21,11 @@
|
||||||
var(--pf-global--spacer--2xl) - var(--pf-global--spacer--sm)
|
var(--pf-global--spacer--2xl) - var(--pf-global--spacer--sm)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pf-c-select.kc-attribute-key-selectable {
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-select.kc-attribute-value-selectable {
|
||||||
|
width: 350px;
|
||||||
|
}
|
|
@ -1,7 +1,14 @@
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
// import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||||
import { Button, TextInput } from "@patternfly/react-core";
|
import {
|
||||||
|
Button,
|
||||||
|
FormGroup,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
SelectVariant,
|
||||||
|
TextInput,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
import {
|
import {
|
||||||
TableComposable,
|
TableComposable,
|
||||||
Tbody,
|
Tbody,
|
||||||
|
@ -11,17 +18,36 @@ import {
|
||||||
Tr,
|
Tr,
|
||||||
} from "@patternfly/react-table";
|
} from "@patternfly/react-table";
|
||||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||||
|
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import "../attribute-form/attribute-form.css";
|
import "../attribute-form/attribute-form.css";
|
||||||
|
import { defaultContextAttributes } from "../../clients/utils";
|
||||||
|
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
|
||||||
|
|
||||||
|
export type AttributeType = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
custom?: boolean;
|
||||||
|
values?: {
|
||||||
|
[key: string]: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
type AttributeInputProps = {
|
type AttributeInputProps = {
|
||||||
name: string;
|
name: string;
|
||||||
|
selectableValues?: string[];
|
||||||
|
isKeySelectable?: boolean;
|
||||||
|
resources?: ResourceRepresentation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AttributeInput = ({ name }: AttributeInputProps) => {
|
export const AttributeInput = ({
|
||||||
|
name,
|
||||||
|
isKeySelectable,
|
||||||
|
selectableValues,
|
||||||
|
}: AttributeInputProps) => {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const { control, register, watch } = useFormContext();
|
const { control, register, watch } = useFormContext();
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove, insert } = useFieldArray({
|
||||||
control: control,
|
control: control,
|
||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
|
@ -32,7 +58,73 @@ export const AttributeInput = ({ name }: AttributeInputProps) => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const watchLast = watch(`${name}[${fields.length - 1}].key`, "");
|
const [isOpenArray, setIsOpenArray] = useState<boolean[]>([false]);
|
||||||
|
const watchLastKey = watch(`${name}[${fields.length - 1}].key`, "");
|
||||||
|
const watchLastValue = watch(`${name}[${fields.length - 1}].value`, "");
|
||||||
|
|
||||||
|
const [valueOpen, setValueOpen] = useState(false);
|
||||||
|
const toggleSelect = (rowIndex: number, open: boolean) => {
|
||||||
|
const arr = [...isOpenArray];
|
||||||
|
arr[rowIndex] = open;
|
||||||
|
setIsOpenArray(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValueInput = (rowIndex: number, attribute: any) => {
|
||||||
|
const attributeValues = defaultContextAttributes.find(
|
||||||
|
(attr) => attr.key === attribute.key
|
||||||
|
)?.values;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Td>
|
||||||
|
{attributeValues?.length ? (
|
||||||
|
<Controller
|
||||||
|
name={`${name}[${rowIndex}].value`}
|
||||||
|
defaultValue={attribute.value}
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Select
|
||||||
|
id={`${attribute.id}-value`}
|
||||||
|
className="kc-attribute-value-selectable"
|
||||||
|
name={`${name}[${rowIndex}].value`}
|
||||||
|
toggleId={`group-${name}`}
|
||||||
|
onToggle={(open) => setValueOpen(open)}
|
||||||
|
isOpen={valueOpen}
|
||||||
|
variant={SelectVariant.typeahead}
|
||||||
|
typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
|
||||||
|
placeholderText={t("clients:selectOrTypeAKey")}
|
||||||
|
selections={value}
|
||||||
|
onSelect={(_, selectedValue) => {
|
||||||
|
remove(rowIndex);
|
||||||
|
insert(rowIndex, {
|
||||||
|
key: attribute.key,
|
||||||
|
value: selectedValue,
|
||||||
|
});
|
||||||
|
onChange(selectedValue);
|
||||||
|
|
||||||
|
setValueOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{attributeValues.map((attribute) => (
|
||||||
|
<SelectOption key={attribute.key} value={attribute.key}>
|
||||||
|
{t(`${attribute.name}`)}
|
||||||
|
</SelectOption>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
id={`$clients:${attribute.key}-value`}
|
||||||
|
className="value-input"
|
||||||
|
name={`${name}[${rowIndex}].value`}
|
||||||
|
ref={register()}
|
||||||
|
defaultValue={attribute.value}
|
||||||
|
data-testid="attribute-value-input"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableComposable
|
<TableComposable
|
||||||
|
@ -55,6 +147,46 @@ export const AttributeInput = ({ name }: AttributeInputProps) => {
|
||||||
{fields.map((attribute, rowIndex) => (
|
{fields.map((attribute, rowIndex) => (
|
||||||
<Tr key={attribute.id} data-testid="attribute-row">
|
<Tr key={attribute.id} data-testid="attribute-row">
|
||||||
<Td>
|
<Td>
|
||||||
|
{isKeySelectable ? (
|
||||||
|
<FormGroup fieldId="test">
|
||||||
|
<Controller
|
||||||
|
name={`${name}[${rowIndex}].key`}
|
||||||
|
defaultValue={attribute.key}
|
||||||
|
control={control}
|
||||||
|
render={({ onChange, value }) => (
|
||||||
|
<Select
|
||||||
|
toggleId="id"
|
||||||
|
id={`${attribute.id}-key`}
|
||||||
|
name={`${name}[${rowIndex}].key`}
|
||||||
|
className="kc-attribute-key-selectable"
|
||||||
|
variant={SelectVariant.typeahead}
|
||||||
|
typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
|
||||||
|
placeholderText={t("clients:selectOrTypeAKey")}
|
||||||
|
onToggle={(open) => toggleSelect(rowIndex, open)}
|
||||||
|
onSelect={(_, selectedValue) => {
|
||||||
|
remove(rowIndex);
|
||||||
|
insert(rowIndex, {
|
||||||
|
key: selectedValue,
|
||||||
|
value: attribute.value,
|
||||||
|
});
|
||||||
|
onChange(selectedValue);
|
||||||
|
|
||||||
|
toggleSelect(rowIndex, false);
|
||||||
|
}}
|
||||||
|
selections={value}
|
||||||
|
aria-label="some label"
|
||||||
|
isOpen={isOpenArray[rowIndex]}
|
||||||
|
>
|
||||||
|
{selectableValues?.map((attribute) => (
|
||||||
|
<SelectOption key={attribute} value={attribute}>
|
||||||
|
{t(`clients:${attribute}`)}
|
||||||
|
</SelectOption>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
id={`${attribute.id}-key`}
|
id={`${attribute.id}-key`}
|
||||||
name={`${name}[${rowIndex}].key`}
|
name={`${name}[${rowIndex}].key`}
|
||||||
|
@ -62,16 +194,9 @@ export const AttributeInput = ({ name }: AttributeInputProps) => {
|
||||||
defaultValue={attribute.key}
|
defaultValue={attribute.key}
|
||||||
data-testid="attribute-key-input"
|
data-testid="attribute-key-input"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
{renderValueInput(rowIndex, attribute)}
|
||||||
<TextInput
|
|
||||||
id={`${attribute.id}-value`}
|
|
||||||
name={`${name}[${rowIndex}].value`}
|
|
||||||
ref={register()}
|
|
||||||
defaultValue={attribute.value}
|
|
||||||
data-testid="attribute-value-input"
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td key="minus-button" id={`kc-minus-button-${rowIndex}`}>
|
<Td key="minus-button" id={`kc-minus-button-${rowIndex}`}>
|
||||||
<Button
|
<Button
|
||||||
id={`minus-button-${rowIndex}`}
|
id={`minus-button-${rowIndex}`}
|
||||||
|
@ -91,9 +216,14 @@ export const AttributeInput = ({ name }: AttributeInputProps) => {
|
||||||
id="plus-icon"
|
id="plus-icon"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="kc-attributes__plus-icon"
|
className="kc-attributes__plus-icon"
|
||||||
onClick={() => append({ key: "", value: "" })}
|
onClick={() => {
|
||||||
|
append({ key: "", value: "" });
|
||||||
|
if (isKeySelectable) {
|
||||||
|
setIsOpenArray([...isOpenArray, false]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
icon={<PlusCircleIcon />}
|
icon={<PlusCircleIcon />}
|
||||||
isDisabled={!watchLast}
|
isDisabled={isKeySelectable ? !watchLastValue : !watchLastKey}
|
||||||
data-testid="attribute-add-row"
|
data-testid="attribute-add-row"
|
||||||
>
|
>
|
||||||
{t("roles:addAttributeText")}
|
{t("roles:addAttributeText")}
|
||||||
|
|
|
@ -39,6 +39,7 @@ import {
|
||||||
ClientRoleRoute,
|
ClientRoleRoute,
|
||||||
toClientRole,
|
toClientRole,
|
||||||
} from "./routes/ClientRole";
|
} from "./routes/ClientRole";
|
||||||
|
import { defaultContextAttributes } from "../clients/utils";
|
||||||
|
|
||||||
export default function RealmRoleTabs() {
|
export default function RealmRoleTabs() {
|
||||||
const { t } = useTranslation("roles");
|
const { t } = useTranslation("roles");
|
||||||
|
@ -377,6 +378,10 @@ export default function RealmRoleTabs() {
|
||||||
title={<TabTitleText>{t("common:attributes")}</TabTitleText>}
|
title={<TabTitleText>{t("common:attributes")}</TabTitleText>}
|
||||||
>
|
>
|
||||||
<AttributesForm
|
<AttributesForm
|
||||||
|
isKeySelectable
|
||||||
|
selectableValues={defaultContextAttributes.map(
|
||||||
|
(item) => item.key
|
||||||
|
)}
|
||||||
form={form}
|
form={form}
|
||||||
save={save}
|
save={save}
|
||||||
reset={() => reset(role)}
|
reset={() => reset(role)}
|
||||||
|
|
|
@ -140,6 +140,8 @@ export const getBaseUrl = (adminClient: KeycloakAdminClient) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const alphaRegexPattern = /[^A-Za-z]/g;
|
||||||
|
|
||||||
export const emailRegexPattern =
|
export const emailRegexPattern =
|
||||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue