Limit search users when users federated or large size (#345)
* when external users then users need to search * added email not verified icon + delete confirm * handle search errors * removed text * fixed label counter
This commit is contained in:
parent
afaa08fbc6
commit
80cca9eca4
5 changed files with 189 additions and 80 deletions
|
@ -44,7 +44,7 @@ export const PaginatingTableToolbar = ({
|
||||||
isCompact
|
isCompact
|
||||||
toggleTemplate={({ firstIndex, lastIndex }: ToggleTemplateProps) => (
|
toggleTemplate={({ firstIndex, lastIndex }: ToggleTemplateProps) => (
|
||||||
<b>
|
<b>
|
||||||
{firstIndex} - {lastIndex}
|
{firstIndex} - {lastIndex! - (count < max ? 1 : 0)}
|
||||||
</b>
|
</b>
|
||||||
)}
|
)}
|
||||||
itemCount={count + page * max + (count <= max ? 1 : 0)}
|
itemCount={count + page * max + (count <= max ? 1 : 0)}
|
||||||
|
|
|
@ -26,7 +26,6 @@ export const SearchUser = ({ onSearch }: SearchUserProps) => {
|
||||||
{t("startBySearchingAUser")}
|
{t("startBySearchingAUser")}
|
||||||
</Title>
|
</Title>
|
||||||
<EmptyStateBody>
|
<EmptyStateBody>
|
||||||
{t("startIntro")}
|
|
||||||
<Form onSubmit={handleSubmit((form) => onSearch(form.search))}>
|
<Form onSubmit={handleSubmit((form) => onSearch(form.search))}>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useContext, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
|
@ -7,15 +7,26 @@ import {
|
||||||
Label,
|
Label,
|
||||||
PageSection,
|
PageSection,
|
||||||
ToolbarItem,
|
ToolbarItem,
|
||||||
|
Tooltip,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { InfoCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons";
|
import {
|
||||||
|
ExclamationCircleIcon,
|
||||||
|
InfoCircleIcon,
|
||||||
|
WarningTriangleIcon,
|
||||||
|
} from "@patternfly/react-icons";
|
||||||
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
|
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
|
||||||
|
|
||||||
import { useAdminClient } from "../context/auth/AdminClient";
|
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
import { RealmContext } from "../context/realm-context/RealmContext";
|
import { RealmContext } from "../context/realm-context/RealmContext";
|
||||||
|
import { SearchUser } from "./SearchUser";
|
||||||
|
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||||
|
import { emptyFormatter } from "../util";
|
||||||
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
|
|
||||||
|
import "./user-section.css";
|
||||||
|
|
||||||
type BruteUser = UserRepresentation & {
|
type BruteUser = UserRepresentation & {
|
||||||
brute?: Record<string, object>;
|
brute?: Record<string, object>;
|
||||||
|
@ -26,19 +37,48 @@ export const UsersSection = () => {
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const { addAlert } = useAlerts();
|
const { addAlert } = useAlerts();
|
||||||
const { realm: realmName } = useContext(RealmContext);
|
const { realm: realmName } = useContext(RealmContext);
|
||||||
|
const [listUsers, setListUsers] = useState(false);
|
||||||
|
const [initialSearch, setInitialSearch] = useState("");
|
||||||
|
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const refresh = () => setKey(`${new Date().getTime()}`);
|
const refresh = () => setKey(`${new Date().getTime()}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return asyncStateFetch(
|
||||||
|
() => {
|
||||||
|
const testParams = {
|
||||||
|
type: "org.keycloak.storage.UserStorageProvider",
|
||||||
|
};
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
adminClient.components.find(testParams),
|
||||||
|
adminClient.users.count(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
//should *only* list users when no user federation is configured and uses count > 100
|
||||||
|
setListUsers(
|
||||||
|
!((response[0] && response[0].length > 0) || response[1] > 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loader = async (first?: number, max?: number, search?: string) => {
|
const loader = async (first?: number, max?: number, search?: string) => {
|
||||||
const params: { [name: string]: string | number } = {
|
const params: { [name: string]: string | number } = {
|
||||||
first: first!,
|
first: first!,
|
||||||
max: max!,
|
max: max!,
|
||||||
};
|
};
|
||||||
if (search) {
|
const searchParam = search || initialSearch || "";
|
||||||
params.search = search;
|
if (searchParam) {
|
||||||
|
params.search = searchParam;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!listUsers && !searchParam) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
const users = await adminClient.users.find({ ...params });
|
const users = await adminClient.users.find({ ...params });
|
||||||
const realm = await adminClient.realms.findOne({ realm: realmName });
|
const realm = await adminClient.realms.findOne({ realm: realmName });
|
||||||
if (realm?.bruteForceProtected) {
|
if (realm?.bruteForceProtected) {
|
||||||
|
@ -54,19 +94,31 @@ export const UsersSection = () => {
|
||||||
user.brute = brutes[index];
|
user.brute = brutes[index];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
|
} catch (error) {
|
||||||
|
addAlert(t("noUsersFoundError", { error }), AlertVariant.danger);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = async (user: UserRepresentation) => {
|
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||||
|
titleKey: "users:deleteConfirm",
|
||||||
|
messageKey: t("deleteConfirmDialog", { count: selectedRows.length }),
|
||||||
|
continueButtonLabel: "delete",
|
||||||
|
continueButtonVariant: ButtonVariant.danger,
|
||||||
|
onConfirm: async () => {
|
||||||
try {
|
try {
|
||||||
|
for (const user of selectedRows) {
|
||||||
await adminClient.users.del({ id: user.id! });
|
await adminClient.users.del({ id: user.id! });
|
||||||
|
}
|
||||||
|
setSelectedRows([]);
|
||||||
refresh();
|
refresh();
|
||||||
addAlert(t("userDeletedSuccess"), AlertVariant.success);
|
addAlert(t("userDeletedSuccess"), AlertVariant.success);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addAlert(t("userDeletedError", { error }), AlertVariant.danger);
|
addAlert(t("userDeletedError", { error }), AlertVariant.danger);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const StatusRow = (user: BruteUser) => {
|
const StatusRow = (user: BruteUser) => {
|
||||||
return (
|
return (
|
||||||
|
@ -81,27 +133,68 @@ export const UsersSection = () => {
|
||||||
{t("temporaryDisabled")}
|
{t("temporaryDisabled")}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
{user.enabled && !user.brute?.disabled && "—"}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ValidatedEmail = (user: UserRepresentation) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!user.emailVerified && (
|
||||||
|
<Tooltip
|
||||||
|
key={`email-verified-${user.id}`}
|
||||||
|
content={<>{t("notVerified")}</>}
|
||||||
|
>
|
||||||
|
<ExclamationCircleIcon className="keycloak__user-section__email-verified" />
|
||||||
|
</Tooltip>
|
||||||
|
)}{" "}
|
||||||
|
{emptyFormatter()(user.email)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewHeader titleKey="users:title" subKey="users:userExplain" />
|
<DeleteConfirm />
|
||||||
|
<ViewHeader titleKey="users:title" subKey="" />
|
||||||
<PageSection variant="light">
|
<PageSection variant="light">
|
||||||
|
{!listUsers && !initialSearch && (
|
||||||
|
<SearchUser
|
||||||
|
onSearch={(search) => {
|
||||||
|
setInitialSearch(search);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(listUsers || initialSearch) && (
|
||||||
<KeycloakDataTable
|
<KeycloakDataTable
|
||||||
key={key}
|
key={key}
|
||||||
loader={loader}
|
loader={loader}
|
||||||
isPaginated
|
isPaginated
|
||||||
ariaLabelKey="users:title"
|
ariaLabelKey="users:title"
|
||||||
searchPlaceholderKey="users:searchForUser"
|
searchPlaceholderKey="users:searchForUser"
|
||||||
|
canSelectAll
|
||||||
|
onSelect={(rows) => setSelectedRows([...rows])}
|
||||||
|
emptyState={
|
||||||
|
<ListEmptyState
|
||||||
|
message={t("noUsersFound")}
|
||||||
|
instructions={t("emptyInstructions")}
|
||||||
|
primaryActionText={t("createNewUser")}
|
||||||
|
onPrimaryAction={() => {}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
toolbarItem={
|
toolbarItem={
|
||||||
<>
|
<>
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<Button>{t("addUser")}</Button>
|
<Button>{t("addUser")}</Button>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<Button variant={ButtonVariant.plain}>{t("deleteUser")}</Button>
|
<Button
|
||||||
|
variant={ButtonVariant.plain}
|
||||||
|
onClick={toggleDeleteDialog}
|
||||||
|
>
|
||||||
|
{t("deleteUser")}
|
||||||
|
</Button>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
@ -109,7 +202,8 @@ export const UsersSection = () => {
|
||||||
{
|
{
|
||||||
title: t("common:delete"),
|
title: t("common:delete"),
|
||||||
onRowClick: (user) => {
|
onRowClick: (user) => {
|
||||||
deleteUser(user);
|
setSelectedRows([user]);
|
||||||
|
toggleDeleteDialog();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
@ -121,14 +215,17 @@ export const UsersSection = () => {
|
||||||
{
|
{
|
||||||
name: "email",
|
name: "email",
|
||||||
displayKey: "users:email",
|
displayKey: "users:email",
|
||||||
|
cellRenderer: ValidatedEmail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "lastName",
|
name: "lastName",
|
||||||
displayKey: "users:lastName",
|
displayKey: "users:lastName",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "firstName",
|
name: "firstName",
|
||||||
displayKey: "users:firstName",
|
displayKey: "users:firstName",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "status",
|
name: "status",
|
||||||
|
@ -137,6 +234,7 @@ export const UsersSection = () => {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
{
|
{
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Users",
|
"title": "Users",
|
||||||
"userExplain": "This is the description",
|
|
||||||
"searchForUser": "Search user",
|
"searchForUser": "Search user",
|
||||||
|
"startBySearchingAUser": "Start by searching a user",
|
||||||
|
"createNewUser": "Create new user",
|
||||||
|
"noUsersFound": "No users found",
|
||||||
|
"noUsersFoundError": "No users found due to {{error}}",
|
||||||
|
"emptyInstructions": "Change your search criteria or add a user",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"lastName": "Last name",
|
"lastName": "Last name",
|
||||||
|
@ -10,9 +14,13 @@
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"temporaryDisabled": "Temporarily disabled",
|
"temporaryDisabled": "Temporarily disabled",
|
||||||
|
"notVerified": "Not verified",
|
||||||
"addUser": "Add user",
|
"addUser": "Add user",
|
||||||
"deleteUser": "Delete user",
|
"deleteUser": "Delete user",
|
||||||
|
"deleteConfirm": "Delete user?",
|
||||||
|
"deleteConfirmDialog": "Are you sure you want to permanently delete {{count}} selected user",
|
||||||
|
"deleteConfirmDialog_plural": "Are you sure you want to permanently delete {{count}} selected users",
|
||||||
"userDeletedSuccess": "The user has been deleted",
|
"userDeletedSuccess": "The user has been deleted",
|
||||||
"userDeletedError": "The user could not be deleted {error}"
|
"userDeletedError": "The user could not be deleted {{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
4
src/user/user-section.css
Normal file
4
src/user/user-section.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
|
||||||
|
.keycloak__user-section__email-verified {
|
||||||
|
color: var(--pf-global--danger-color--100);
|
||||||
|
}
|
Loading…
Reference in a new issue