User Attribute Search (#19574)
Co-authored-by: Andreas Blaettlinger <bln1imb@bosch.com>
This commit is contained in:
parent
79ff5ded63
commit
35736b86ae
11 changed files with 645 additions and 58 deletions
|
@ -4,6 +4,7 @@
|
|||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"reset": "Zurücksetzen",
|
||||
"remove": "Entfernen",
|
||||
"search": "Suche",
|
||||
"key": "Schlüssel",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"continue": "Continue",
|
||||
"close": "Close",
|
||||
"delete": "Delete",
|
||||
"reset": "Reset",
|
||||
"remove": "Remove",
|
||||
"revoke": "Revoke",
|
||||
"search": "Search",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteConfirm />
|
||||
<UnlockUsersConfirm />
|
||||
<KeycloakDataTable
|
||||
key={key}
|
||||
loader={loader}
|
||||
isPaginated
|
||||
ariaLabelKey="users:title"
|
||||
searchPlaceholderKey="users:searchForUser"
|
||||
canSelectAll
|
||||
onSelect={(rows: any[]) => 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") {
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={t("common:search")}
|
||||
onClick={refresh}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</ToolbarItem>
|
||||
<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}
|
||||
/>
|
||||
</ToolbarContent>
|
||||
);
|
||||
};
|
||||
|
||||
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"
|
||||
canSelectAll
|
||||
onSelect={(rows: UserRepresentation[]) => setSelectedRows([...rows])}
|
||||
emptyState={
|
||||
!listUsers ? (
|
||||
<>
|
||||
<Toolbar>
|
||||
<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 [];
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
79
js/apps/admin-ui/src/user/details/SearchFilter.tsx
Normal file
79
js/apps/admin-ui/src/user/details/SearchFilter.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
3
js/apps/admin-ui/src/user/utils.ts
Normal file
3
js/apps/admin-ui/src/user/utils.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const isBundleKey = (displayName?: string) =>
|
||||
displayName?.includes("${");
|
||||
export const unWrap = (key: string) => key.substring(2, key.length - 1);
|
Loading…
Reference in a new issue