Clients: Authorization -> Evaluate tab (#1861)

This commit is contained in:
Jenny 2022-01-23 15:21:19 -05:00 committed by GitHub
parent 109c255d90
commit dea54e674a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 584 additions and 70 deletions

View file

@ -60,6 +60,9 @@ import { AuthorizationResources } from "./authorization/Resources";
import { AuthorizationScopes } from "./authorization/Scopes"; import { AuthorizationScopes } from "./authorization/Scopes";
import { AuthorizationPolicies } from "./authorization/Policies"; import { AuthorizationPolicies } from "./authorization/Policies";
import { AuthorizationPermissions } from "./authorization/Permissions"; import { AuthorizationPermissions } from "./authorization/Permissions";
import { AuthorizationEvaluate } from "./authorization/AuthorizationEvaluate";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
type ClientDetailHeaderProps = { type ClientDetailHeaderProps = {
onChange: (value: boolean) => void; onChange: (value: boolean) => void;
@ -195,6 +198,24 @@ export default function ClientDetails() {
}); });
const [client, setClient] = useState<ClientRepresentation>(); const [client, setClient] = useState<ClientRepresentation>();
const [clients, setClients] = useState<ClientRepresentation[]>([]);
const [clientRoles, setClientRoles] = useState<RoleRepresentation[]>([]);
const [users, setUsers] = useState<UserRepresentation[]>([]);
useFetch(
() =>
Promise.all([
adminClient.clients.find(),
adminClient.roles.find(),
adminClient.users.find(),
]),
([clients, roles, users]) => {
setClients(clients);
setClientRoles(roles);
setUsers(users);
},
[]
);
const loader = async () => { const loader = async () => {
const roles = await adminClient.clients.listRoles({ id: clientId }); const roles = await adminClient.clients.listRoles({ id: clientId });
@ -508,6 +529,20 @@ export default function ClientDetails() {
> >
<AuthorizationPermissions clientId={clientId} /> <AuthorizationPermissions clientId={clientId} />
</Tab> </Tab>
<Tab
id="Evaluate"
eventKey={44}
title={<TabTitleText>{t("evaluate")}</TabTitleText>}
>
<AuthorizationEvaluate
clients={clients}
clientName={client.clientId}
clientRoles={clientRoles}
users={users}
save={save}
reset={() => setupForm(client)}
/>
</Tab>
</Tabs> </Tabs>
</Tab> </Tab>
)} )}

View file

@ -0,0 +1,425 @@
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
FormGroup,
Select,
SelectVariant,
SelectOption,
PageSection,
ActionGroup,
Button,
Switch,
ExpandableSection,
TextInput,
} from "@patternfly/react-core";
import { Controller, useFormContext } from "react-hook-form";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { FormPanel } from "../../components/scroll-form/FormPanel";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import type ResourceEvaluation from "@keycloak/keycloak-admin-client/lib/defs/resourceEvaluation";
import { useRealm } from "../../context/realm-context/RealmContext";
import { AttributeInput } from "../../components/attribute-input/AttributeInput";
import { defaultContextAttributes } from "../utils";
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
import { useParams } from "react-router-dom";
import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation";
export type AttributeType = {
key: string;
name: string;
custom?: boolean;
values?: {
[key: string]: string;
}[];
};
type ClientSettingsProps = {
clients: ClientRepresentation[];
clientName?: string;
save: () => void;
reset: () => void;
users: UserRepresentation[];
clientRoles: RoleRepresentation[];
};
export const AuthorizationEvaluate = ({
clients,
clientRoles,
clientName,
users,
reset,
}: ClientSettingsProps) => {
const form = useFormContext<ResourceEvaluation>();
const { control } = form;
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const realm = useRealm();
const { clientId } = useParams<{ clientId: string }>();
const [clientsDropdownOpen, setClientsDropdownOpen] = useState(false);
const [scopesDropdownOpen, setScopesDropdownOpen] = useState(false);
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const [roleDropdownOpen, setRoleDropdownOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [applyToResourceType, setApplyToResourceType] = useState(false);
const [resources, setResources] = useState<ResourceRepresentation[]>([]);
const [scopes, setScopes] = useState<ScopeRepresentation[]>([]);
const [selectedClient, setSelectedClient] = useState<ClientRepresentation>();
const [selectedUser, setSelectedUser] = useState<UserRepresentation>();
useFetch(
async () =>
Promise.all([
adminClient.clients.listResources({
id: clientId,
}),
adminClient.clients.listAllScopes({
id: clientId,
}),
]),
([resources, scopes]) => {
setResources(resources);
setScopes(scopes);
},
[]
);
const evaluate = (formValues: ResourceEvaluation) => {
const resEval: ResourceEvaluation = {
roleIds: formValues.roleIds ?? [],
userId: selectedUser?.id!,
entitlements: false,
context: formValues.context,
resources: formValues.resources,
clientId: selectedClient?.id!,
};
return adminClient.clients.evaluateResource(
{ id: clientId!, realm: realm.realm },
resEval
);
};
return (
<PageSection>
<FormPanel
className="kc-identity-information"
title={t("clients:identityInformation")}
>
<FormAccess
isHorizontal
role="manage-clients"
onSubmit={form.handleSubmit(evaluate)}
>
<FormGroup
label={t("client")}
isRequired
labelIcon={
<HelpItem
helpText="clients-help:client"
fieldLabelId="clients:client"
/>
}
fieldId="client"
>
<Controller
name="client"
defaultValue={clientName}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="client"
onToggle={setClientsDropdownOpen}
onSelect={(_, value) => {
setSelectedClient(value as ClientRepresentation);
onChange((value as ClientRepresentation).clientId);
setClientsDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.typeahead}
aria-label={t("client")}
isOpen={clientsDropdownOpen}
>
{clients.map((client) => (
<SelectOption
selected={client === value}
key={client.clientId}
value={client}
>
{client.clientId}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("user")}
isRequired
labelIcon={
<HelpItem
helpText="clients-help:userSelect"
fieldLabelId="clients:userSelect"
/>
}
fieldId="loginTheme"
>
<Controller
name="userId"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="user"
placeholderText={t("selectAUser")}
onToggle={setUserDropdownOpen}
onSelect={(_, value) => {
setSelectedUser(value as UserRepresentation);
onChange((value as UserRepresentation).username);
setUserDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.typeahead}
aria-label={t("user")}
isOpen={userDropdownOpen}
>
{users.map((user) => (
<SelectOption
selected={user.username === value}
key={user.username}
value={user}
>
{user.username}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("roles")}
labelIcon={
<HelpItem
helpText="clients-help:roles"
fieldLabelId="clients:roles"
/>
}
fieldId="realmRole"
>
<Controller
name="rolesIds"
placeholderText={t("selectARole")}
control={control}
defaultValue={[]}
render={({ onChange, value }) => (
<Select
variant={SelectVariant.typeaheadMulti}
toggleId="role"
onToggle={setRoleDropdownOpen}
selections={value}
onSelect={(_, v) => {
const option = v.toString();
if (value.includes(option)) {
onChange(value.filter((item: string) => item !== option));
} else {
onChange([...value, option]);
}
setRoleDropdownOpen(false);
}}
onClear={(event) => {
event.stopPropagation();
onChange([]);
}}
aria-label={t("realmRole")}
isOpen={roleDropdownOpen}
>
{clientRoles.map((role) => (
<SelectOption
selected={role.name === value}
key={role.name}
value={role.name}
/>
))}
</Select>
)}
/>
</FormGroup>
</FormAccess>
</FormPanel>
<FormPanel className="kc-permissions" title={t("permissions")}>
<FormAccess isHorizontal role="manage-clients">
<FormGroup
label={t("applyToResourceType")}
fieldId="applyToResourceType"
labelIcon={
<HelpItem
helpText="clients-help:applyToResourceType"
fieldLabelId="clients:applyToResourceType"
/>
}
>
<Controller
name="applyToResource"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Switch
id="applyToResource-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => {
onChange(value.toString());
setApplyToResourceType(value);
}}
/>
)}
/>
</FormGroup>
{!applyToResourceType && (
<FormGroup
label={t("resourcesAndAuthScopes")}
id="resourcesAndAuthScopes"
isRequired
labelIcon={
<HelpItem
helpText={t("clients-help:contextualAttributes")}
fieldLabelId={`resourcesAndAuthScopes`}
/>
}
helperTextInvalid={t("common:required")}
fieldId={name!}
>
<AttributeInput
selectableValues={resources.map((item) => item.name!)}
resources={resources}
isKeySelectable
name="resources"
/>
</FormGroup>
)}
{applyToResourceType && (
<>
<FormGroup
label={t("resourceType")}
isRequired
labelIcon={
<HelpItem
helpText="clients-help:resourceType"
fieldLabelId="clients:resourceType"
/>
}
fieldId="client"
>
<TextInput
type="text"
id="alias"
name="alias"
data-testid="alias"
ref={form.register({ required: true })}
/>
</FormGroup>
<FormGroup
label={t("authScopes")}
labelIcon={
<HelpItem
helpText="clients-help:scopesSelect"
fieldLabelId="clients:client"
/>
}
fieldId="authScopes"
>
<Controller
name="authScopes"
defaultValue={[]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="authScopes"
onToggle={setScopesDropdownOpen}
onSelect={(_, v) => {
const option = v.toString();
if (value.includes(option)) {
onChange(
value.filter((item: string) => item !== option)
);
} else {
onChange([...value, option]);
}
setScopesDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.typeaheadMulti}
aria-label={t("authScopes")}
isOpen={scopesDropdownOpen}
>
{scopes.map((scope) => (
<SelectOption
selected={scope.name === value}
key={scope.id}
value={scope.name}
/>
))}
</Select>
)}
/>
</FormGroup>
</>
)}
<ExpandableSection
toggleText={t("contextualInfo")}
onToggle={() => setIsExpanded(!isExpanded)}
isExpanded={isExpanded}
>
<FormGroup
label={t("contextualAttributes")}
id="contextualAttributes"
labelIcon={
<HelpItem
helpText={t("clients-help:contextualAttributes")}
fieldLabelId={`contextualAttributes`}
/>
}
helperTextInvalid={t("common:required")}
fieldId={name!}
>
<AttributeInput
selectableValues={defaultContextAttributes.map(
(item) => item.name
)}
isKeySelectable
name="context"
/>
</FormGroup>
</ExpandableSection>
<ActionGroup>
<Button data-testid="authorization-eval" type="submit">
{t("evaluate")}
</Button>
<Button
data-testid="authorization-revert"
variant="link"
onClick={reset}
>
{t("common:revert")}
</Button>
<Button
data-testid="authorization-revert"
variant="primary"
onClick={reset}
isDisabled
>
{t("lastEvaluation")}
</Button>
</ActionGroup>
</FormAccess>
</FormPanel>
</PageSection>
);
};

View file

@ -44,8 +44,23 @@ export default {
"Default URL to use when the auth server needs to redirect or link back to the client.", "Default URL to use when the auth server needs to redirect or link back to the client.",
adminURL: adminURL:
"URL to the admin interface of the client. Set this if the client supports the adapter REST API. This REST API allows the auth server to push revocation policies and other administrative tasks. Usually this is set to the base URL of the client.", "URL to the admin interface of the client. Set this if the client supports the adapter REST API. This REST API allows the auth server to push revocation policies and other administrative tasks. Usually this is set to the base URL of the client.",
client:
"Select the client making this authorization request. If not provided, authorization requests would be done based on the client you are in.",
clientId: clientId:
"Specifies ID referenced in URI and tokens. For example 'my-client'. For SAML this is also the expected issuer value from authn requests", "Specifies ID referenced in URI and tokens. For example 'my-client'. For SAML this is also the expected issuer value from authn requests",
selectUser:
"Select a user whose identity is going to be used to query permissions from the server.",
roles: "Select the roles you want to associate with the selected user.",
contextualAttributes:
"Any attribute provided by a running environment or execution context.",
resourceType:
"Specifies that this permission must be applied to all resource instances of a given type.",
applyToResourceType:
"Specifies if this permission should be applied to all resources with a given type. In this case, this permission will be evaluated for all instances of a given resource type.",
resources:
"Specifies that this permission must be applied to a specific resource instance.",
scopesSelect:
"Specifies that this permission must be applied to one or more scopes.",
clientName: clientName:
"Specifies display name of the client. For example 'My Client'. Supports keys for localized values as well. For example: ${my_client}", "Specifies display name of the client. For example 'My Client'. Supports keys for localized values as well. For example: ${my_client}",
description: description:

View file

@ -36,7 +36,18 @@ export default {
clientScopeError: "Could not update the scope mapping {{error}}", clientScopeError: "Could not update the scope mapping {{error}}",
searchByName: "Search by name", searchByName: "Search by name",
setup: "Setup", setup: "Setup",
selectAUser: "Select a user",
client: "Client",
evaluate: "Evaluate", evaluate: "Evaluate",
lastEvaluation: "Last Evaluation",
resourcesAndAuthScopes: "Resources and Authentication Scopes",
authScopes: "Authorization scopes",
anyResource: "Any resource",
anyScope: "Any scope",
selectScope: "Select a scope",
applyToResourceType: "Apply to Resource Type",
contextualInfo: "Contextual Information",
contextualAttributes: "Contextual Attributes",
selectOrTypeAKey: "Select or type a key", selectOrTypeAKey: "Select or type a key",
custom: "Custom Attribute...", custom: "Custom Attribute...",
kc: { kc: {
@ -128,6 +139,7 @@ export default {
"The permissions below will be removed when they are no longer used by other resources:", "The permissions below will be removed when they are no longer used by other resources:",
resourceDeletedSuccess: "The resource successfully deleted", resourceDeletedSuccess: "The resource successfully deleted",
resourceDeletedError: "Could not remove the resource {{error}}", resourceDeletedError: "Could not remove the resource {{error}}",
identityInformation: "Identity Information",
permissions: "Permissions", permissions: "Permissions",
searchForPermission: "Search for permission", searchForPermission: "Search for permission",
deleteScope: "Permanently delete authorization scope?", deleteScope: "Permanently delete authorization scope?",

View file

@ -27,5 +27,9 @@
} }
.pf-c-select.kc-attribute-value-selectable { .pf-c-select.kc-attribute-value-selectable {
width: 500px;
}
.pf-c-form-control.value-input {
width: 350px; width: 350px;
} }

View file

@ -1,9 +1,8 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// import { Controller, useFieldArray, useFormContext } from "react-hook-form"; import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { import {
Button, Button,
FormGroup,
Select, Select,
SelectOption, SelectOption,
SelectVariant, SelectVariant,
@ -18,10 +17,10 @@ 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 { defaultContextAttributes } from "../../clients/utils";
import { camelCase } from "lodash";
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation"; import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
export type AttributeType = { export type AttributeType = {
@ -44,10 +43,11 @@ export const AttributeInput = ({
name, name,
isKeySelectable, isKeySelectable,
selectableValues, selectableValues,
resources,
}: AttributeInputProps) => { }: AttributeInputProps) => {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const { control, register, watch } = useFormContext(); const { control, register, watch, getValues } = useFormContext();
const { fields, append, remove, insert } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control: control, control: control,
name, name,
}); });
@ -58,63 +58,90 @@ export const AttributeInput = ({
} }
}, []); }, []);
const [isOpenArray, setIsOpenArray] = useState<boolean[]>([false]); const [isKeyOpenArray, setIsKeyOpenArray] = useState([false]);
const watchLastKey = watch(`${name}[${fields.length - 1}].key`, ""); const watchLastKey = watch(`${name}[${fields.length - 1}].key`, "");
const watchLastValue = watch(`${name}[${fields.length - 1}].value`, ""); const watchLastValue = watch(`${name}[${fields.length - 1}].value`, "");
const [valueOpen, setValueOpen] = useState(false); const [isValueOpenArray, setIsValueOpenArray] = useState([false]);
const toggleSelect = (rowIndex: number, open: boolean) => { const toggleKeySelect = (rowIndex: number, open: boolean) => {
const arr = [...isOpenArray]; const arr = [...isKeyOpenArray];
arr[rowIndex] = open; arr[rowIndex] = open;
setIsOpenArray(arr); setIsKeyOpenArray(arr);
};
const toggleValueSelect = (rowIndex: number, open: boolean) => {
const arr = [...isValueOpenArray];
arr[rowIndex] = open;
setIsValueOpenArray(arr);
}; };
const renderValueInput = (rowIndex: number, attribute: any) => { const renderValueInput = (rowIndex: number, attribute: any) => {
const attributeValues = defaultContextAttributes.find( let attributeValues: { key: string; name: string }[] | undefined = [];
(attr) => attr.key === attribute.key
)?.values; const scopeValues = resources?.find(
(resource) => resource.name === getValues().resources[rowIndex]?.key
)?.scopes;
if (selectableValues) {
attributeValues = defaultContextAttributes.find(
(attr) => attr.name === getValues().context[rowIndex]?.key
)?.values;
}
const getMessageBundleKey = (attributeName: string) =>
camelCase(attributeName).replace(/\W/g, "");
return ( return (
<Td> <Td>
{attributeValues?.length ? ( {scopeValues?.length || attributeValues?.length ? (
<Controller <Controller
name={`${name}[${rowIndex}].value`} name={`${name}[${rowIndex}].value`}
defaultValue={attribute.value} defaultValue={[]}
control={control} control={control}
render={({ onChange, value }) => ( render={({ onChange, value }) => (
<Select <Select
id={`${attribute.id}-value`} id={`${attribute.id}-value`}
className="kc-attribute-value-selectable" className="kc-attribute-value-selectable"
name={`${name}[${rowIndex}].value`} name={`${name}[${rowIndex}].value`}
chipGroupProps={{
numChips: 1,
expandedText: t("common:hide"),
collapsedText: t("common:showRemaining"),
}}
toggleId={`group-${name}`} toggleId={`group-${name}`}
onToggle={(open) => setValueOpen(open)} onToggle={(open) => toggleValueSelect(rowIndex, open)}
isOpen={valueOpen} isOpen={isValueOpenArray[rowIndex]}
variant={SelectVariant.typeahead} variant={
resources
? SelectVariant.typeaheadMulti
: SelectVariant.typeahead
}
typeAheadAriaLabel={t("clients:selectOrTypeAKey")} typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
placeholderText={t("clients:selectOrTypeAKey")} placeholderText={t("clients:selectOrTypeAKey")}
selections={value} selections={value}
onSelect={(_, selectedValue) => { onSelect={(_, v) => {
remove(rowIndex); if (resources) {
insert(rowIndex, { const option = v.toString();
key: attribute.key, if (value.includes(option)) {
value: selectedValue, onChange(value.filter((item: string) => item !== option));
}); } else {
onChange(selectedValue); onChange([...value, option]);
}
setValueOpen(false); } else {
onChange(v);
}
toggleValueSelect(rowIndex, false);
}} }}
> >
{attributeValues.map((attribute) => ( {(scopeValues || attributeValues)?.map((scope) => (
<SelectOption key={attribute.key} value={attribute.key}> <SelectOption key={scope.name} value={scope.name} />
{t(`${attribute.name}`)}
</SelectOption>
))} ))}
</Select> </Select>
)} )}
/> />
) : ( ) : (
<TextInput <TextInput
id={`$clients:${attribute.key}-value`} id={`${getMessageBundleKey(attribute.key)}-value`}
className="value-input" className="value-input"
name={`${name}[${rowIndex}].value`} name={`${name}[${rowIndex}].value`}
ref={register()} ref={register()}
@ -148,44 +175,40 @@ export const AttributeInput = ({
<Tr key={attribute.id} data-testid="attribute-row"> <Tr key={attribute.id} data-testid="attribute-row">
<Td> <Td>
{isKeySelectable ? ( {isKeySelectable ? (
<FormGroup fieldId="test"> <Controller
<Controller name={`${name}[${rowIndex}].key`}
name={`${name}[${rowIndex}].key`} defaultValue={attribute.key}
defaultValue={attribute.key} control={control}
control={control} render={({ onChange, value }) => (
render={({ onChange, value }) => ( <Select
<Select id={`${name}[${rowIndex}].key`}
toggleId="id" className="kc-attribute-key-selectable"
id={`${attribute.id}-key`} name={`${name}[${rowIndex}].key`}
name={`${name}[${rowIndex}].key`} toggleId={`group-${name}`}
className="kc-attribute-key-selectable" onToggle={(open) => toggleKeySelect(rowIndex, open)}
variant={SelectVariant.typeahead} isOpen={isKeyOpenArray[rowIndex]}
typeAheadAriaLabel={t("clients:selectOrTypeAKey")} variant={SelectVariant.typeahead}
placeholderText={t("clients:selectOrTypeAKey")} typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
onToggle={(open) => toggleSelect(rowIndex, open)} placeholderText={t("clients:selectOrTypeAKey")}
onSelect={(_, selectedValue) => { selections={value}
remove(rowIndex); onSelect={(_, v) => {
insert(rowIndex, { onChange(v);
key: selectedValue,
value: attribute.value,
});
onChange(selectedValue);
toggleSelect(rowIndex, false); toggleKeySelect(rowIndex, false);
}} }}
selections={value} >
aria-label="some label" {selectableValues?.map((attribute) => (
isOpen={isOpenArray[rowIndex]} <SelectOption
> selected={attribute === value}
{selectableValues?.map((attribute) => ( key={attribute}
<SelectOption key={attribute} value={attribute}> value={attribute}
{t(`clients:${attribute}`)} >
</SelectOption> {attribute}
))} </SelectOption>
</Select> ))}
)} </Select>
/> )}
</FormGroup> />
) : ( ) : (
<TextInput <TextInput
id={`${attribute.id}-key`} id={`${attribute.id}-key`}
@ -219,7 +242,7 @@ export const AttributeInput = ({
onClick={() => { onClick={() => {
append({ key: "", value: "" }); append({ key: "", value: "" });
if (isKeySelectable) { if (isKeySelectable) {
setIsOpenArray([...isOpenArray, false]); setIsKeyOpenArray([...isKeyOpenArray, false]);
} }
}} }}
icon={<PlusCircleIcon />} icon={<PlusCircleIcon />}