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:
Jenny 2022-01-17 14:26:42 -05:00 committed by GitHub
parent dcfb84e7d1
commit 105d0dffb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 254 additions and 26 deletions

View file

@ -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?",

View file

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

View file

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

View file

@ -20,4 +20,12 @@
--pf-c-form__group--m-action--MarginTop: calc( --pf-c-form__group--m-action--MarginTop: calc(
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;
} }

View file

@ -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,23 +147,56 @@ 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>
<TextInput {isKeySelectable ? (
id={`${attribute.id}-key`} <FormGroup fieldId="test">
name={`${name}[${rowIndex}].key`} <Controller
ref={register()} name={`${name}[${rowIndex}].key`}
defaultValue={attribute.key} defaultValue={attribute.key}
data-testid="attribute-key-input" control={control}
/> render={({ onChange, value }) => (
</Td> <Select
<Td> toggleId="id"
<TextInput id={`${attribute.id}-key`}
id={`${attribute.id}-value`} name={`${name}[${rowIndex}].key`}
name={`${name}[${rowIndex}].value`} className="kc-attribute-key-selectable"
ref={register()} variant={SelectVariant.typeahead}
defaultValue={attribute.value} typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
data-testid="attribute-value-input" 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
id={`${attribute.id}-key`}
name={`${name}[${rowIndex}].key`}
ref={register()}
defaultValue={attribute.key}
data-testid="attribute-key-input"
/>
)}
</Td> </Td>
{renderValueInput(rowIndex, attribute)}
<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")}

View file

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

View file

@ -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,}))$/;