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",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
"reset": "Zurücksetzen",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
"search": "Suche",
|
"search": "Suche",
|
||||||
"key": "Schlüssel",
|
"key": "Schlüssel",
|
||||||
|
|
|
@ -3,6 +3,14 @@
|
||||||
"usersExplain": "Benutzer in diesem Realm.",
|
"usersExplain": "Benutzer in diesem Realm.",
|
||||||
"userList": "Benutzerliste",
|
"userList": "Benutzerliste",
|
||||||
"searchForUser": "Benutzer suchen",
|
"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",
|
"join": "Beitreten",
|
||||||
"leave": "Verlassen",
|
"leave": "Verlassen",
|
||||||
"groupMembership": "Gruppen-Mitglied",
|
"groupMembership": "Gruppen-Mitglied",
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"reset": "Reset",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"revoke": "Revoke",
|
"revoke": "Revoke",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
|
|
@ -3,6 +3,14 @@
|
||||||
"usersExplain": "Users are the users in the current realm.",
|
"usersExplain": "Users are the users in the current realm.",
|
||||||
"userList": "User list",
|
"userList": "User list",
|
||||||
"searchForUser": "Search user",
|
"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",
|
"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.",
|
"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",
|
"createUser": "Create user",
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
|
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
|
||||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
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 type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
|
Chip,
|
||||||
|
ChipGroup,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
InputGroup,
|
FlexItem,
|
||||||
Label,
|
Label,
|
||||||
Text,
|
Text,
|
||||||
TextContent,
|
TextContent,
|
||||||
TextInput,
|
|
||||||
Toolbar,
|
Toolbar,
|
||||||
ToolbarContent,
|
ToolbarContent,
|
||||||
ToolbarItem,
|
ToolbarItem,
|
||||||
|
@ -19,7 +21,6 @@ import {
|
||||||
import {
|
import {
|
||||||
ExclamationCircleIcon,
|
ExclamationCircleIcon,
|
||||||
InfoCircleIcon,
|
InfoCircleIcon,
|
||||||
SearchIcon,
|
|
||||||
WarningTriangleIcon,
|
WarningTriangleIcon,
|
||||||
} from "@patternfly/react-icons";
|
} from "@patternfly/react-icons";
|
||||||
import type { IRowData } from "@patternfly/react-table";
|
import type { IRowData } from "@patternfly/react-table";
|
||||||
|
@ -40,6 +41,13 @@ import { useFetch } from "../../utils/useFetch";
|
||||||
import { toAddUser } from "../../user/routes/AddUser";
|
import { toAddUser } from "../../user/routes/AddUser";
|
||||||
import { toUser } from "../../user/routes/User";
|
import { toUser } from "../../user/routes/User";
|
||||||
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
|
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems";
|
||||||
|
import { SearchType } from "../../user/details/SearchFilter";
|
||||||
|
|
||||||
|
export type UserAttribute = {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function UserDataTable() {
|
export function UserDataTable() {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
|
@ -47,9 +55,13 @@ export function UserDataTable() {
|
||||||
const { realm: realmName } = useRealm();
|
const { realm: realmName } = useRealm();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [userStorage, setUserStorage] = useState<ComponentRepresentation[]>();
|
const [userStorage, setUserStorage] = useState<ComponentRepresentation[]>();
|
||||||
const [searchUser, setSearchUser] = useState<string>();
|
const [searchUser, setSearchUser] = useState("");
|
||||||
const [realm, setRealm] = useState<RealmRepresentation | undefined>();
|
const [realm, setRealm] = useState<RealmRepresentation | undefined>();
|
||||||
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
|
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 [key, setKey] = useState(0);
|
||||||
const refresh = () => setKey(key + 1);
|
const refresh = () => setKey(key + 1);
|
||||||
|
@ -64,19 +76,22 @@ export function UserDataTable() {
|
||||||
return await Promise.all([
|
return await Promise.all([
|
||||||
adminClient.components.find(testParams),
|
adminClient.components.find(testParams),
|
||||||
adminClient.realms.findOne({ realm: realmName }),
|
adminClient.realms.findOne({ realm: realmName }),
|
||||||
|
adminClient.users.getProfile(),
|
||||||
]);
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
return [[], {}] as [
|
return [[], {}, {}] as [
|
||||||
ComponentRepresentation[],
|
ComponentRepresentation[],
|
||||||
RealmRepresentation | undefined
|
RealmRepresentation | undefined,
|
||||||
|
UserProfileConfig
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
([storageProviders, realm]) => {
|
([storageProviders, realm, profile]) => {
|
||||||
setUserStorage(
|
setUserStorage(
|
||||||
storageProviders.filter((p) => p.config?.enabled[0] === "true")
|
storageProviders.filter((p) => p.config?.enabled[0] === "true")
|
||||||
);
|
);
|
||||||
setRealm(realm);
|
setRealm(realm);
|
||||||
|
setProfile(profile);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
@ -94,6 +109,7 @@ export function UserDataTable() {
|
||||||
const params: { [name: string]: string | number } = {
|
const params: { [name: string]: string | number } = {
|
||||||
first: first!,
|
first: first!,
|
||||||
max: max!,
|
max: max!,
|
||||||
|
q: query!,
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchParam = search || searchUser || "";
|
const searchParam = search || searchUser || "";
|
||||||
|
@ -146,7 +162,7 @@ export function UserDataTable() {
|
||||||
await adminClient.users.del({ id: user.id! });
|
await adminClient.users.del({ id: user.id! });
|
||||||
}
|
}
|
||||||
setSelectedRows([]);
|
setSelectedRows([]);
|
||||||
refresh();
|
clearAllFilters();
|
||||||
addAlert(t("userDeletedSuccess"), AlertVariant.success);
|
addAlert(t("userDeletedSuccess"), AlertVariant.success);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError("users:userDeletedError", error);
|
addError("users:userDeletedError", error);
|
||||||
|
@ -197,56 +213,126 @@ export function UserDataTable() {
|
||||||
//should *only* list users when no user federation is configured
|
//should *only* list users when no user federation is configured
|
||||||
const listUsers = !(userStorage.length > 0);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DeleteConfirm />
|
<DeleteConfirm />
|
||||||
<UnlockUsersConfirm />
|
<UnlockUsersConfirm />
|
||||||
<KeycloakDataTable
|
<KeycloakDataTable
|
||||||
|
isSearching
|
||||||
key={key}
|
key={key}
|
||||||
loader={loader}
|
loader={loader}
|
||||||
isPaginated
|
isPaginated
|
||||||
ariaLabelKey="users:title"
|
ariaLabelKey="users:title"
|
||||||
searchPlaceholderKey="users:searchForUser"
|
|
||||||
canSelectAll
|
canSelectAll
|
||||||
onSelect={(rows: any[]) => setSelectedRows([...rows])}
|
onSelect={(rows: UserRepresentation[]) => setSelectedRows([...rows])}
|
||||||
emptyState={
|
emptyState={
|
||||||
!listUsers ? (
|
!listUsers ? (
|
||||||
<>
|
<>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarContent>
|
<ToolbarContent>{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>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<EmptyState data-testid="empty-state" variant="large">
|
<EmptyState data-testid="empty-state" variant="large">
|
||||||
<TextContent className="kc-search-users-text">
|
<TextContent className="kc-search-users-text">
|
||||||
|
@ -263,15 +349,8 @@ export function UserDataTable() {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
toolbarItem={
|
toolbarItem={toolbar()}
|
||||||
<UserDataTableToolbarItems
|
subToolbar={subtoolbar()}
|
||||||
realm={realm}
|
|
||||||
hasSelectedRows={selectedRows.length === 0}
|
|
||||||
toggleDeleteDialog={toggleDeleteDialog}
|
|
||||||
toggleUnlockUsersDialog={toggleUnlockUsersDialog}
|
|
||||||
goToCreate={goToCreate}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
actionResolver={(rowData: IRowData) => {
|
actionResolver={(rowData: IRowData) => {
|
||||||
const user: UserRepresentation = rowData.data;
|
const user: UserRepresentation = rowData.data;
|
||||||
if (!user.access?.manage) return [];
|
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 RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||||
|
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
DropdownToggle,
|
||||||
|
InputGroup,
|
||||||
KebabToggle,
|
KebabToggle,
|
||||||
|
SearchInput,
|
||||||
ToolbarItem,
|
ToolbarItem,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useState } from "react";
|
import { ReactNode, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAccess } from "../../context/access/Access";
|
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 = {
|
type UserDataTableToolbarItemsProps = {
|
||||||
realm: RealmRepresentation;
|
realm: RealmRepresentation;
|
||||||
|
@ -17,6 +25,17 @@ type UserDataTableToolbarItemsProps = {
|
||||||
toggleDeleteDialog: () => void;
|
toggleDeleteDialog: () => void;
|
||||||
toggleUnlockUsersDialog: () => void;
|
toggleUnlockUsersDialog: () => void;
|
||||||
goToCreate: () => 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({
|
export function UserDataTableToolbarItems({
|
||||||
|
@ -25,9 +44,21 @@ export function UserDataTableToolbarItems({
|
||||||
toggleDeleteDialog,
|
toggleDeleteDialog,
|
||||||
toggleUnlockUsersDialog,
|
toggleUnlockUsersDialog,
|
||||||
goToCreate,
|
goToCreate,
|
||||||
|
searchType,
|
||||||
|
setSearchType,
|
||||||
|
searchUser,
|
||||||
|
setSearchUser,
|
||||||
|
activeFilters,
|
||||||
|
setActiveFilters,
|
||||||
|
refresh,
|
||||||
|
profile,
|
||||||
|
clearAllFilters,
|
||||||
|
createAttributeSearchChips,
|
||||||
|
searchUserWithAttributes,
|
||||||
}: UserDataTableToolbarItemsProps) {
|
}: UserDataTableToolbarItemsProps) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const [kebabOpen, setKebabOpen] = useState(false);
|
const [kebabOpen, setKebabOpen] = useState(false);
|
||||||
|
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const { hasAccess } = useAccess();
|
const { hasAccess } = useAccess();
|
||||||
|
|
||||||
|
@ -37,6 +68,88 @@ export function UserDataTableToolbarItems({
|
||||||
// permissions of every group.
|
// permissions of every group.
|
||||||
const isManager = hasAccess("query-users");
|
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 ? (
|
const bruteForceProtectionToolbarItem = !realm.bruteForceProtected ? (
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<Button
|
<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 { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
|
||||||
import { ScrollForm } from "../components/scroll-form/ScrollForm";
|
import { ScrollForm } from "../components/scroll-form/ScrollForm";
|
||||||
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
|
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
|
||||||
|
import { isBundleKey, unWrap } from "./utils";
|
||||||
import useToggle from "../utils/useToggle";
|
import useToggle from "../utils/useToggle";
|
||||||
|
|
||||||
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
|
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
|
||||||
|
@ -80,9 +81,6 @@ const FormField = ({ attribute, roles }: FormFieldProps) => {
|
||||||
} = useFormContext();
|
} = useFormContext();
|
||||||
const [open, toggle] = useToggle();
|
const [open, toggle] = useToggle();
|
||||||
|
|
||||||
const isBundleKey = (displayName?: string) => displayName?.includes("${");
|
|
||||||
const unWrap = (key: string) => key.substring(2, key.length - 1);
|
|
||||||
|
|
||||||
const isSelect = (attribute: UserProfileAttribute) =>
|
const isSelect = (attribute: UserProfileAttribute) =>
|
||||||
Object.hasOwn(attribute.validations || {}, "options");
|
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 > * {
|
.pf-c-table tbody > tr > * {
|
||||||
white-space: nowrap;
|
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