User Attribute Search (#19574)

Co-authored-by: Andreas Blaettlinger <bln1imb@bosch.com>
This commit is contained in:
Andreas Blättlinger 2023-06-12 14:17:54 +02:00 committed by GitHub
parent 79ff5ded63
commit 35736b86ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 645 additions and 58 deletions

View file

@ -4,6 +4,7 @@
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"reset": "Zurücksetzen",
"remove": "Entfernen",
"search": "Suche",
"key": "Schlüssel",

View file

@ -3,6 +3,14 @@
"usersExplain": "Benutzer in diesem Realm.",
"userList": "Benutzerliste",
"searchForUser": "Benutzer suchen",
"searchType.default": "Standartsuche",
"searchType.attribute": "Attributsuche",
"selectAttribute": "Wähle Attribut",
"selectAttributes": "Wähle Attribute",
"searchUserByAttributeMissingKeyError": "Attributschlüssel angeben",
"searchUserByAttributeKeyAlreadyInUseError": "Attributschlüssel bereits in Verwendung",
"searchUserByAttributeMissingValueError": "Attributwert angeben",
"searchUserByAttributeDescription": "Es unterstützt die Einstellung mehrerer Attribute als Suchfilter, indem verschiedene Schlüssel oder Werte festgelegt werden. Für einen Schlüssel kann nur ein Wert eingegeben werden.",
"join": "Beitreten",
"leave": "Verlassen",
"groupMembership": "Gruppen-Mitglied",

View file

@ -12,6 +12,7 @@
"continue": "Continue",
"close": "Close",
"delete": "Delete",
"reset": "Reset",
"remove": "Remove",
"revoke": "Revoke",
"search": "Search",

View file

@ -3,6 +3,14 @@
"usersExplain": "Users are the users in the current realm.",
"userList": "User list",
"searchForUser": "Search user",
"searchType.default": "Default search",
"searchType.attribute": "Attribute search",
"selectAttribute": "Select attribute",
"selectAttributes": "Select attributes",
"searchUserByAttributeMissingKeyError": "Specify a attribute key",
"searchUserByAttributeKeyAlreadyInUseError": "Attribute key already in use",
"searchUserByAttributeMissingValueError": "Specify a attribute value",
"searchUserByAttributeDescription": "It supports setting multiple attributes as the search filter by setting different keys or values. Only one value can be typed for a key.",
"startBySearchingAUser": "Start by searching for users",
"searchForUserDescription": "This realm may have a federated provider. Viewing all users may cause the system to slow down, but it can be done by searching for \"*\". Please search for a user above.",
"createUser": "Create user",

View file

@ -1,16 +1,18 @@
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import {
AlertVariant,
Button,
ButtonVariant,
Chip,
ChipGroup,
EmptyState,
InputGroup,
FlexItem,
Label,
Text,
TextContent,
TextInput,
Toolbar,
ToolbarContent,
ToolbarItem,
@ -19,7 +21,6 @@ import {
import {
ExclamationCircleIcon,
InfoCircleIcon,
SearchIcon,
WarningTriangleIcon,
} from "@patternfly/react-icons";
import type { IRowData } from "@patternfly/react-table";
@ -40,6 +41,13 @@ import { useFetch } from "../../utils/useFetch";
import { toAddUser } from "../../user/routes/AddUser";
import { toUser } from "../../user/routes/User";
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
import { SearchType } from "../../user/details/SearchFilter";
export type UserAttribute = {
name: string;
displayName: string;
value: string;
};
export function UserDataTable() {
const { t } = useTranslation("users");
@ -47,9 +55,13 @@ export function UserDataTable() {
const { realm: realmName } = useRealm();
const navigate = useNavigate();
const [userStorage, setUserStorage] = useState<ComponentRepresentation[]>();
const [searchUser, setSearchUser] = useState<string>();
const [searchUser, setSearchUser] = useState("");
const [realm, setRealm] = useState<RealmRepresentation | undefined>();
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
const [searchType, setSearchType] = useState<SearchType>("default");
const [activeFilters, setActiveFilters] = useState<UserAttribute[]>([]);
const [profile, setProfile] = useState<UserProfileConfig>({});
const [query, setQuery] = useState("");
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
@ -64,19 +76,22 @@ export function UserDataTable() {
return await Promise.all([
adminClient.components.find(testParams),
adminClient.realms.findOne({ realm: realmName }),
adminClient.users.getProfile(),
]);
} catch {
return [[], {}] as [
return [[], {}, {}] as [
ComponentRepresentation[],
RealmRepresentation | undefined
RealmRepresentation | undefined,
UserProfileConfig
];
}
},
([storageProviders, realm]) => {
([storageProviders, realm, profile]) => {
setUserStorage(
storageProviders.filter((p) => p.config?.enabled[0] === "true")
);
setRealm(realm);
setProfile(profile);
},
[]
);
@ -94,6 +109,7 @@ export function UserDataTable() {
const params: { [name: string]: string | number } = {
first: first!,
max: max!,
q: query!,
};
const searchParam = search || searchUser || "";
@ -146,7 +162,7 @@ export function UserDataTable() {
await adminClient.users.del({ id: user.id! });
}
setSelectedRows([]);
refresh();
clearAllFilters();
addAlert(t("userDeletedSuccess"), AlertVariant.success);
} catch (error) {
addError("users:userDeletedError", error);
@ -197,56 +213,126 @@ export function UserDataTable() {
//should *only* list users when no user federation is configured
const listUsers = !(userStorage.length > 0);
const clearAllFilters = () => {
const filtered = [...activeFilters].filter(
(chip) => chip.name !== chip.name
);
setActiveFilters(filtered);
setSearchUser("");
setQuery("");
refresh();
};
const createQueryString = (filters: UserAttribute[]) => {
return filters.map((filter) => `${filter.name}:${filter.value}`).join(" ");
};
const searchUserWithAttributes = () => {
const attributes = createQueryString(activeFilters);
setQuery(attributes);
refresh();
};
const createAttributeSearchChips = () => {
return (
<FlexItem>
{activeFilters.length > 0 && (
<>
{Object.values(activeFilters).map((entry) => {
return (
<ChipGroup
className="pf-u-mt-md pf-u-mr-md"
key={entry.name}
categoryName={
entry.displayName.length ? entry.displayName : entry.name
}
isClosable
onClick={(event) => {
event.stopPropagation();
const filtered = [...activeFilters].filter(
(chip) => chip.name !== entry.name
);
const attributes = createQueryString(filtered);
setActiveFilters(filtered);
setQuery(attributes);
refresh();
}}
>
<Chip key={entry.name} isReadOnly>
{entry.value}
</Chip>
</ChipGroup>
);
})}
</>
)}
</FlexItem>
);
};
const toolbar = () => {
return (
<UserDataTableToolbarItems
realm={realm}
hasSelectedRows={selectedRows.length === 0}
toggleDeleteDialog={toggleDeleteDialog}
toggleUnlockUsersDialog={toggleUnlockUsersDialog}
goToCreate={goToCreate}
searchType={searchType}
setSearchType={setSearchType}
searchUser={searchUser}
setSearchUser={setSearchUser}
activeFilters={activeFilters}
setActiveFilters={setActiveFilters}
refresh={refresh}
profile={profile}
clearAllFilters={clearAllFilters}
createAttributeSearchChips={createAttributeSearchChips}
searchUserWithAttributes={searchUserWithAttributes}
/>
);
};
const subtoolbar = () => {
if (!activeFilters.length) {
return;
}
return (
<div className="user-attribute-search-form-subtoolbar">
<ToolbarItem>{createAttributeSearchChips()}</ToolbarItem>
<ToolbarItem>
<Button
variant="link"
onClick={() => {
clearAllFilters();
}}
>
{t("common:clearAllFilters")}
</Button>
</ToolbarItem>
</div>
);
};
return (
<>
<DeleteConfirm />
<UnlockUsersConfirm />
<KeycloakDataTable
isSearching
key={key}
loader={loader}
isPaginated
ariaLabelKey="users:title"
searchPlaceholderKey="users:searchForUser"
canSelectAll
onSelect={(rows: any[]) => setSelectedRows([...rows])}
onSelect={(rows: UserRepresentation[]) => setSelectedRows([...rows])}
emptyState={
!listUsers ? (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<InputGroup>
<TextInput
name="search-input"
type="search"
aria-label={t("search")}
placeholder={t("users:searchForUser")}
onChange={(value) => {
setSearchUser(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
refresh();
}
}}
/>
<Button
variant={ButtonVariant.control}
aria-label={t("common:search")}
onClick={refresh}
>
<SearchIcon />
</Button>
</InputGroup>
</ToolbarItem>
<UserDataTableToolbarItems
realm={realm}
hasSelectedRows={selectedRows.length === 0}
toggleDeleteDialog={toggleDeleteDialog}
toggleUnlockUsersDialog={toggleUnlockUsersDialog}
goToCreate={goToCreate}
/>
</ToolbarContent>
<ToolbarContent>{toolbar()}</ToolbarContent>
</Toolbar>
<EmptyState data-testid="empty-state" variant="large">
<TextContent className="kc-search-users-text">
@ -263,15 +349,8 @@ export function UserDataTable() {
/>
)
}
toolbarItem={
<UserDataTableToolbarItems
realm={realm}
hasSelectedRows={selectedRows.length === 0}
toggleDeleteDialog={toggleDeleteDialog}
toggleUnlockUsersDialog={toggleUnlockUsersDialog}
goToCreate={goToCreate}
/>
}
toolbarItem={toolbar()}
subToolbar={subtoolbar()}
actionResolver={(rowData: IRowData) => {
const user: UserRepresentation = rowData.data;
if (!user.access?.manage) return [];

View file

@ -0,0 +1,255 @@
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import {
ActionGroup,
Alert,
AlertVariant,
Button,
ButtonVariant,
InputGroup,
Select,
SelectOption,
SelectVariant,
Text,
TextContent,
TextVariants,
} from "@patternfly/react-core";
import { Form } from "react-router-dom";
import { KeycloakTextInput } from "../keycloak-text-input/KeycloakTextInput";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { isBundleKey, unWrap } from "../../user/utils";
import { CheckIcon } from "@patternfly/react-icons";
import { useAlerts } from "../alert/Alerts";
import { ReactNode, useState } from "react";
import { UserAttribute } from "./UserDataTable";
type UserDataTableAttributeSearchFormProps = {
activeFilters: UserAttribute[];
setActiveFilters: (filters: UserAttribute[]) => void;
profile: UserProfileConfig;
createAttributeSearchChips: () => ReactNode;
searchUserWithAttributes: () => void;
};
export function UserDataTableAttributeSearchForm({
activeFilters,
setActiveFilters,
profile,
createAttributeSearchChips,
searchUserWithAttributes,
}: UserDataTableAttributeSearchFormProps) {
const { t } = useTranslation("users");
const { addAlert } = useAlerts();
const [selectAttributeKeyOpen, setSelectAttributeKeyOpen] = useState(false);
const defaultValues: UserAttribute = {
name: "",
displayName: "",
value: "",
};
const {
getValues,
register,
reset,
formState: { errors },
setValue,
setError,
clearErrors,
} = useForm<UserAttribute>({
mode: "onChange",
defaultValues,
});
const isAttributeKeyDuplicate = () => {
return activeFilters.some((filter) => filter.name === getValues().name);
};
const isAttributeNameValid = () => {
let valid = false;
if (!getValues().name.length) {
setError("name", {
type: "empty",
message: t("searchUserByAttributeMissingKeyError"),
});
} else if (
activeFilters.some((filter) => filter.name === getValues().name)
) {
setError("name", {
type: "conflict",
message: t("searchUserByAttributeKeyAlreadyInUseError"),
});
} else {
valid = true;
}
return valid;
};
const isAttributeValueValid = () => {
let valid = false;
if (!getValues().value.length) {
setError("value", {
type: "empty",
message: t("searchUserByAttributeMissingValueError"),
});
} else {
valid = true;
}
return valid;
};
const isAttributeValid = () =>
isAttributeNameValid() && isAttributeValueValid();
const addToFilter = () => {
if (isAttributeValid()) {
setActiveFilters([
...activeFilters,
{
...getValues(),
},
]);
reset();
} else {
errors.name?.message &&
addAlert(errors.name.message, AlertVariant.danger);
errors.value?.message &&
addAlert(errors.value.message, AlertVariant.danger);
}
};
const clearActiveFilters = () => {
const filtered = [...activeFilters].filter(
(chip) => chip.name !== chip.name
);
setActiveFilters(filtered);
};
const createAttributeKeyInputField = () => {
if (profile) {
return (
<Select
data-testid="search-attribute-name"
variant={SelectVariant.typeahead}
onToggle={(isOpen) => setSelectAttributeKeyOpen(isOpen)}
selections={getValues().displayName}
onSelect={(_, selectedValue) => {
setValue("displayName", selectedValue.toString());
if (isAttributeKeyDuplicate()) {
setError("name", { type: "conflict" });
} else {
clearErrors("name");
}
}}
isOpen={selectAttributeKeyOpen}
placeholderText={t("selectAttribute")}
validated={errors.name && "error"}
maxHeight={300}
{...register("displayName", {
required: true,
validate: isAttributeNameValid,
})}
>
{profile.attributes?.map((option) => (
<SelectOption
key={option.name}
value={
(isBundleKey(option.displayName)
? t(unWrap(option.displayName!))
: option.displayName) || option.name
}
onClick={(e) => {
e.stopPropagation();
setSelectAttributeKeyOpen(false);
setValue("name", option.name!);
}}
/>
))}
</Select>
);
} else {
return (
<KeycloakTextInput
id="name"
placeholder={t("common:keyPlaceholder")}
validated={errors.name && "error"}
onKeyDown={(e) => e.key === "Enter" && addToFilter()}
{...register("name", {
required: true,
validate: isAttributeNameValid,
})}
/>
);
}
};
return (
<Form className="user-attribute-search-form">
<TextContent className="user-attribute-search-form-headline">
<Text component={TextVariants.h6}>{t("selectAttributes")}</Text>
</TextContent>
<Alert
isInline
className="user-attribute-search-form-alert"
variant="info"
title={t("searchUserByAttributeDescription")}
/>
<TextContent className="user-attribute-search-form-key-value">
<div className="user-attribute-search-form-left">
<Text component={TextVariants.h6}>{t("common:key")}</Text>
</div>
<div className="user-attribute-search-form-right">
<Text component={TextVariants.h6}>{t("common:value")}</Text>
</div>
</TextContent>
<div className="user-attribute-search-form-left">
{createAttributeKeyInputField()}
</div>
<div className="user-attribute-search-form-right">
<InputGroup>
<KeycloakTextInput
id="value"
placeholder={t("common:valuePlaceholder")}
validated={errors.value && "error"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addToFilter();
}
}}
{...register("value", {
required: true,
validate: isAttributeValueValid,
})}
/>
<Button
variant="control"
icon={<CheckIcon />}
onClick={addToFilter}
/>
</InputGroup>
</div>
{createAttributeSearchChips()}
<ActionGroup className="user-attribute-search-form-action-group">
<Button
data-testid="search-user-attribute-btn"
variant="primary"
type="submit"
isDisabled={!activeFilters.length}
onClick={searchUserWithAttributes}
>
{t("common:search")}
</Button>
<Button
variant={ButtonVariant.link}
onClick={() => {
reset();
clearActiveFilters();
}}
>
{t("common:reset")}
</Button>
</ActionGroup>
</Form>
);
}

View file

@ -1,15 +1,23 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import {
Button,
ButtonVariant,
Dropdown,
DropdownItem,
DropdownToggle,
InputGroup,
KebabToggle,
SearchInput,
ToolbarItem,
} from "@patternfly/react-core";
import { useState } from "react";
import { ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAccess } from "../../context/access/Access";
import { UserDataTableAttributeSearchForm } from "./UserDataTableAttributeSearchForm";
import { ArrowRightIcon } from "@patternfly/react-icons";
import { SearchDropdown, SearchType } from "../../user/details/SearchFilter";
import { UserAttribute } from "./UserDataTable";
type UserDataTableToolbarItemsProps = {
realm: RealmRepresentation;
@ -17,6 +25,17 @@ type UserDataTableToolbarItemsProps = {
toggleDeleteDialog: () => void;
toggleUnlockUsersDialog: () => void;
goToCreate: () => void;
searchType: SearchType;
setSearchType: (searchType: SearchType) => void;
searchUser: string;
setSearchUser: (searchUser: string) => void;
activeFilters: UserAttribute[];
setActiveFilters: (activeFilters: UserAttribute[]) => void;
refresh: () => void;
profile: UserProfileConfig;
clearAllFilters: () => void;
createAttributeSearchChips: () => ReactNode;
searchUserWithAttributes: () => void;
};
export function UserDataTableToolbarItems({
@ -25,9 +44,21 @@ export function UserDataTableToolbarItems({
toggleDeleteDialog,
toggleUnlockUsersDialog,
goToCreate,
searchType,
setSearchType,
searchUser,
setSearchUser,
activeFilters,
setActiveFilters,
refresh,
profile,
clearAllFilters,
createAttributeSearchChips,
searchUserWithAttributes,
}: UserDataTableToolbarItemsProps) {
const { t } = useTranslation("users");
const [kebabOpen, setKebabOpen] = useState(false);
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
const { hasAccess } = useAccess();
@ -37,6 +68,88 @@ export function UserDataTableToolbarItems({
// permissions of every group.
const isManager = hasAccess("query-users");
const searchItem = () => {
return (
<ToolbarItem>
<InputGroup>
<SearchDropdown
searchType={searchType}
onSelect={(searchType) => {
clearAllFilters();
setSearchType(searchType);
}}
/>
{searchType === "default" && defaultSearchInput()}
{searchType === "attribute" && attributeSearchInput()}
</InputGroup>
</ToolbarItem>
);
};
const defaultSearchInput = () => {
return (
<ToolbarItem>
<SearchInput
placeholder={t("searchForUser")}
aria-label={t("search")}
value={searchUser}
onChange={(_, value) => {
setSearchUser(value);
}}
onSearch={() => {
setSearchUser(searchUser);
refresh();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSearchUser(searchUser);
refresh();
}
}}
onClear={() => {
setSearchUser("");
}}
/>
</ToolbarItem>
);
};
const attributeSearchInput = () => {
return (
<>
<Dropdown
id="user-attribute-search-select"
data-testid="UserAttributeSearchSelector"
toggle={
<DropdownToggle
data-testid="userAttributeSearchSelectorToggle"
onToggle={(isOpen) => {
setSearchDropdownOpen(isOpen);
}}
className="keycloak__user_attribute_search_selector_dropdown__toggle"
>
{t("selectAttributes")}
</DropdownToggle>
}
isOpen={searchDropdownOpen}
>
<UserDataTableAttributeSearchForm
activeFilters={activeFilters}
setActiveFilters={setActiveFilters}
profile={profile}
createAttributeSearchChips={createAttributeSearchChips}
searchUserWithAttributes={searchUserWithAttributes}
/>
</Dropdown>
<Button
icon={<ArrowRightIcon />}
variant="control"
onClick={searchUserWithAttributes}
/>
</>
);
};
const bruteForceProtectionToolbarItem = !realm.bruteForceProtected ? (
<ToolbarItem>
<Button
@ -93,5 +206,10 @@ export function UserDataTableToolbarItems({
</>
);
return isManager ? actionItems : null;
return (
<>
{searchItem()}
{isManager ? actionItems : null}
</>
);
}

View file

@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
import { isBundleKey, unWrap } from "./utils";
import useToggle from "../utils/useToggle";
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
@ -80,9 +81,6 @@ const FormField = ({ attribute, roles }: FormFieldProps) => {
} = 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");

View file

@ -0,0 +1,79 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dropdown,
DropdownItem,
DropdownToggle,
Select,
SelectOption,
ToolbarItem,
} from "@patternfly/react-core";
import { FilterIcon } from "@patternfly/react-icons";
export type SearchType = "default" | "attribute";
type SearchToolbarProps = SearchDropdownProps;
type SearchDropdownProps = {
searchType: SearchType;
onSelect: (value: SearchType) => void;
};
export const SearchDropdown = ({
searchType,
onSelect,
}: SearchDropdownProps) => {
const { t } = useTranslation("users");
const [searchToggle, setSearchToggle] = useState(false);
const createDropdown = (searchType: SearchType) => (
<DropdownItem
key={searchType}
onClick={() => {
onSelect(searchType);
setSearchToggle(false);
}}
>
{t(`searchType.${searchType}`)}
</DropdownItem>
);
const options = [createDropdown("default"), createDropdown("attribute")];
return (
<Dropdown
className="keycloak__users__searchtype"
toggle={
<DropdownToggle id="toggle-id" onToggle={setSearchToggle}>
<FilterIcon /> {t(`searchType.${searchType}`)}
</DropdownToggle>
}
isOpen={searchToggle}
dropdownItems={options}
/>
);
};
export const SearchToolbar = ({ searchType, onSelect }: SearchToolbarProps) => {
const { t } = useTranslation("users");
const [open, setOpen] = useState(false);
return (
<>
<ToolbarItem>
<SearchDropdown searchType={searchType} onSelect={onSelect} />
</ToolbarItem>
<ToolbarItem>
<Select
className="keycloak__users__searchtype"
onToggle={setOpen}
isOpen={open}
selections={[t("default"), t("attribute")]}
onSelect={() => setOpen(false)}
>
<SelectOption value={"default"}>{t("default")}</SelectOption>
<SelectOption value={"attribute"}>{t("attribute")}</SelectOption>
</Select>
</ToolbarItem>
</>
);
};

View file

@ -147,3 +147,40 @@ article.pf-c-card.pf-m-flat.kc-available-idps > div > div > h1 {
.pf-c-table tbody > tr > * {
white-space: nowrap;
}
.user-attribute-search-form {
display: block;
padding: 20px;
width: 700px;
}
.user-attribute-search-form-headline {
margin-bottom: 1.5rem;
}
.user-attribute-search-form-alert {
margin-bottom: 1.5rem;
}
.user-attribute-search-form-key-value {
margin-bottom: .5rem;
}
.user-attribute-search-form-left {
display: inline-block;
width: 35%;
}
.user-attribute-search-form-right {
display: inline-block;
width: 65%;
}
.user-attribute-search-form-action-group button {
margin-top: 2rem;
}
.user-attribute-search-form-subtoolbar {
margin-top: -2rem;
display: ruby;
}

View file

@ -0,0 +1,3 @@
export const isBundleKey = (displayName?: string) =>
displayName?.includes("${");
export const unWrap = (key: string) => key.substring(2, key.length - 1);