From ab8366fb7e5d28ce7b3b4c94b5909c329c03a18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Bl=C3=A4ttlinger?= <69153350+andreas-blaettlinger@users.noreply.github.com> Date: Mon, 15 May 2023 17:18:45 +0200 Subject: [PATCH] Split UsersSection into components for better maintainability (#19848) Closes #19847 --- .../src/components/users/UserDataTable.tsx | 288 ++++++++++++++ .../users/UserDataTableToolbarItems.tsx | 97 +++++ js/apps/admin-ui/src/user/UsersSection.tsx | 373 +----------------- 3 files changed, 392 insertions(+), 366 deletions(-) create mode 100644 js/apps/admin-ui/src/components/users/UserDataTable.tsx create mode 100644 js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx diff --git a/js/apps/admin-ui/src/components/users/UserDataTable.tsx b/js/apps/admin-ui/src/components/users/UserDataTable.tsx new file mode 100644 index 0000000000..2f75aebc74 --- /dev/null +++ b/js/apps/admin-ui/src/components/users/UserDataTable.tsx @@ -0,0 +1,288 @@ +import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import { + AlertVariant, + ButtonVariant, + EmptyState, + Label, + Text, + TextContent, + Toolbar, + ToolbarContent, + Tooltip, +} from "@patternfly/react-core"; +import { + ExclamationCircleIcon, + InfoCircleIcon, + WarningTriangleIcon, +} from "@patternfly/react-icons"; +import type { IRowData } from "@patternfly/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; + +import { adminClient } from "../../admin-client"; +import { useAlerts } from "../alert/Alerts"; +import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; +import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner"; +import { ListEmptyState } from "../list-empty-state/ListEmptyState"; +import { BruteUser, findUsers } from "../role-mapping/resource"; +import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { emptyFormatter } from "../../util"; +import { useFetch } from "../../utils/useFetch"; +import { toAddUser } from "../../user/routes/AddUser"; +import { toUser } from "../../user/routes/User"; +import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems"; + +export function UserDataTable() { + const { t } = useTranslation("users"); + const { addAlert, addError } = useAlerts(); + const { realm: realmName } = useRealm(); + const navigate = useNavigate(); + const [userStorage, setUserStorage] = useState(); + const [realm, setRealm] = useState(); + const [selectedRows, setSelectedRows] = useState([]); + + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + + useFetch( + async () => { + const testParams = { + type: "org.keycloak.storage.UserStorageProvider", + }; + + try { + return await Promise.all([ + adminClient.components.find(testParams), + adminClient.realms.findOne({ realm: realmName }), + ]); + } catch { + return [[], {}] as [ + ComponentRepresentation[], + RealmRepresentation | undefined + ]; + } + }, + ([storageProviders, realm]) => { + setUserStorage( + storageProviders.filter((p) => p.config?.enabled[0] === "true") + ); + setRealm(realm); + }, + [] + ); + + const UserDetailLink = (user: UserRepresentation) => ( + + {user.username} + + ); + + const loader = async (first?: number, max?: number, search?: string) => { + const params: { [name: string]: string | number } = { + first: first!, + max: max!, + }; + + const searchParam = search || ""; + if (searchParam) { + params.search = searchParam; + } + + if (!listUsers && !searchParam) { + return []; + } + + try { + return await findUsers({ + briefRepresentation: true, + ...params, + }); + } catch (error) { + if (userStorage?.length) { + addError("users:noUsersFoundErrorStorage", error); + } else { + addError("users:noUsersFoundError", error); + } + return []; + } + }; + + const [toggleUnlockUsersDialog, UnlockUsersConfirm] = useConfirmDialog({ + titleKey: "users:unlockAllUsers", + messageKey: "users:unlockUsersConfirm", + continueButtonLabel: "users:unlock", + onConfirm: async () => { + try { + await adminClient.attackDetection.delAll(); + refresh(); + addAlert(t("unlockUsersSuccess"), AlertVariant.success); + } catch (error) { + addError("users:unlockUsersError", error); + } + }, + }); + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "users:deleteConfirm", + messageKey: t("deleteConfirmDialog", { count: selectedRows.length }), + continueButtonLabel: "delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + for (const user of selectedRows) { + await adminClient.users.del({ id: user.id! }); + } + setSelectedRows([]); + refresh(); + addAlert(t("userDeletedSuccess"), AlertVariant.success); + } catch (error) { + addError("users:userDeletedError", error); + } + }, + }); + + const StatusRow = (user: BruteUser) => { + return ( + <> + {!user.enabled && ( + + )} + {user.bruteForceStatus?.disabled && ( + + )} + {user.enabled && !user.bruteForceStatus?.disabled && "—"} + + ); + }; + + const ValidatedEmail = (user: UserRepresentation) => { + return ( + <> + {!user.emailVerified && ( + {t("notVerified")}} + > + + + )}{" "} + {emptyFormatter()(user.email)} + + ); + }; + + const goToCreate = () => navigate(toAddUser({ realm: realmName })); + + if (!userStorage || !realm) { + return ; + } + + //should *only* list users when no user federation is configured + const listUsers = !(userStorage.length > 0); + + return ( + <> + + + setSelectedRows([...rows])} + emptyState={ + !listUsers ? ( + <> + + + + + + + + {t("searchForUserDescription")} + + + + ) : ( + + ) + } + toolbarItem={ + + } + actionResolver={(rowData: IRowData) => { + const user: UserRepresentation = rowData.data; + if (!user.access?.manage) return []; + + return [ + { + title: t("common:delete"), + onClick: () => { + setSelectedRows([user]); + toggleDeleteDialog(); + }, + }, + ]; + }} + columns={[ + { + name: "username", + displayKey: "users:username", + cellRenderer: UserDetailLink, + }, + { + name: "email", + displayKey: "users:email", + cellRenderer: ValidatedEmail, + }, + { + name: "lastName", + displayKey: "users:lastName", + cellFormatters: [emptyFormatter()], + }, + { + name: "firstName", + displayKey: "users:firstName", + cellFormatters: [emptyFormatter()], + }, + { + name: "status", + displayKey: "users:status", + cellRenderer: StatusRow, + }, + ]} + /> + + ); +} diff --git a/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx b/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx new file mode 100644 index 0000000000..658ae70959 --- /dev/null +++ b/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx @@ -0,0 +1,97 @@ +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { + Button, + ButtonVariant, + Dropdown, + DropdownItem, + KebabToggle, + ToolbarItem, +} from "@patternfly/react-core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAccess } from "../../context/access/Access"; + +type UserDataTableToolbarItemsProps = { + realm: RealmRepresentation; + hasSelectedRows: boolean; + toggleDeleteDialog: () => void; + toggleUnlockUsersDialog: () => void; + goToCreate: () => void; +}; + +export function UserDataTableToolbarItems({ + realm, + hasSelectedRows, + toggleDeleteDialog, + toggleUnlockUsersDialog, + goToCreate, +}: UserDataTableToolbarItemsProps) { + const { t } = useTranslation("users"); + const [kebabOpen, setKebabOpen] = useState(false); + + const { hasAccess } = useAccess(); + + // Only needs query-users access to attempt add/delete of users. + // This is because the user could have fine-grained access to users + // of a group. There is no way to know this without searching the + // permissions of every group. + const isManager = hasAccess("query-users"); + + const bruteForceProtectionToolbarItem = !realm.bruteForceProtected ? ( + + + + ) : ( + + setKebabOpen(open)} />} + isOpen={kebabOpen} + isPlain + dropdownItems={[ + { + toggleDeleteDialog(); + setKebabOpen(false); + }} + > + {t("deleteUser")} + , + + { + toggleUnlockUsersDialog(); + setKebabOpen(false); + }} + > + {t("unlockAllUsers")} + , + ]} + /> + + ); + + const actionItems = ( + <> + + + + {bruteForceProtectionToolbarItem} + + ); + + return isManager ? actionItems : null; +} diff --git a/js/apps/admin-ui/src/user/UsersSection.tsx b/js/apps/admin-ui/src/user/UsersSection.tsx index 062ec180c3..d498ecbd33 100644 --- a/js/apps/admin-ui/src/user/UsersSection.tsx +++ b/js/apps/admin-ui/src/user/UsersSection.tsx @@ -1,113 +1,24 @@ -import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; -import { - AlertVariant, - Button, - ButtonVariant, - Dropdown, - DropdownItem, - EmptyState, - InputGroup, - KebabToggle, - Label, - PageSection, - Tab, - TabTitleText, - Text, - TextContent, - TextInput, - Toolbar, - ToolbarContent, - ToolbarItem, - Tooltip, -} from "@patternfly/react-core"; -import { - ExclamationCircleIcon, - InfoCircleIcon, - SearchIcon, - WarningTriangleIcon, -} from "@patternfly/react-icons"; -import type { IRowData } from "@patternfly/react-table"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; +import { PageSection, Tab, TabTitleText } from "@patternfly/react-core"; -import { adminClient } from "../admin-client"; -import { useAlerts } from "../components/alert/Alerts"; -import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; -import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; -import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +import { useRealm } from "../context/realm-context/RealmContext"; +import helpUrls from "../help-urls"; import { PermissionsTab } from "../components/permission-tab/PermissionTab"; -import { BruteUser, findUsers } from "../components/role-mapping/resource"; +import { UserDataTable } from "../components/users/UserDataTable"; +import { toUsers, UserTab } from "./routes/Users"; import { RoutableTabs, useRoutableTab, } from "../components/routable-tabs/RoutableTabs"; -import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; -import { ViewHeader } from "../components/view-header/ViewHeader"; -import { useAccess } from "../context/access/Access"; -import { useRealm } from "../context/realm-context/RealmContext"; -import helpUrls from "../help-urls"; -import { emptyFormatter } from "../util"; -import { useFetch } from "../utils/useFetch"; import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; -import { toAddUser } from "./routes/AddUser"; -import { toUser } from "./routes/User"; -import { UserTab, toUsers } from "./routes/Users"; - import "./user-section.css"; export default function UsersSection() { const { t } = useTranslation("users"); - const { addAlert, addError } = useAlerts(); const { realm: realmName } = useRealm(); - const navigate = useNavigate(); - const [userStorage, setUserStorage] = useState(); - const [searchUser, setSearchUser] = useState(); - const [realm, setRealm] = useState(); - const [kebabOpen, setKebabOpen] = useState(false); - const [selectedRows, setSelectedRows] = useState([]); const isFeatureEnabled = useIsFeatureEnabled(); - const [key, setKey] = useState(0); - const refresh = () => setKey(key + 1); - - const { hasAccess } = useAccess(); - - // Only needs query-users access to attempt add/delete of users. - // This is because the user could have fine-grained access to users - // of a group. There is no way to know this without searching the - // permissions of every group. - const isManager = hasAccess("query-users"); - - useFetch( - async () => { - const testParams = { - type: "org.keycloak.storage.UserStorageProvider", - }; - - try { - return await Promise.all([ - adminClient.components.find(testParams), - adminClient.realms.findOne({ realm: realmName }), - ]); - } catch { - return [[], {}] as [ - ComponentRepresentation[], - RealmRepresentation | undefined - ]; - } - }, - ([storageProviders, realm]) => { - setUserStorage( - storageProviders.filter((p) => p.config?.enabled[0] === "true") - ); - setRealm(realm); - }, - [] - ); - const useTab = (tab: UserTab) => useRoutableTab( toUsers({ @@ -119,180 +30,8 @@ export default function UsersSection() { const listTab = useTab("list"); const permissionsTab = useTab("permissions"); - const UserDetailLink = (user: UserRepresentation) => ( - - {user.username} - - ); - - const loader = async (first?: number, max?: number, search?: string) => { - const params: { [name: string]: string | number } = { - first: first!, - max: max!, - }; - - const searchParam = search || searchUser || ""; - if (searchParam) { - params.search = searchParam; - } - - if (!listUsers && !searchParam) { - return []; - } - - try { - return await findUsers({ - briefRepresentation: true, - ...params, - }); - } catch (error) { - if (userStorage?.length) { - addError("users:noUsersFoundErrorStorage", error); - } else { - addError("users:noUsersFoundError", error); - } - return []; - } - }; - - const [toggleUnlockUsersDialog, UnlockUsersConfirm] = useConfirmDialog({ - titleKey: "users:unlockAllUsers", - messageKey: "users:unlockUsersConfirm", - continueButtonLabel: "users:unlock", - onConfirm: async () => { - try { - await adminClient.attackDetection.delAll(); - refresh(); - addAlert(t("unlockUsersSuccess"), AlertVariant.success); - } catch (error) { - addError("users:unlockUsersError", error); - } - }, - }); - - const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ - titleKey: "users:deleteConfirm", - messageKey: t("deleteConfirmDialog", { count: selectedRows.length }), - continueButtonLabel: "delete", - continueButtonVariant: ButtonVariant.danger, - onConfirm: async () => { - try { - for (const user of selectedRows) { - await adminClient.users.del({ id: user.id! }); - } - setSelectedRows([]); - refresh(); - addAlert(t("userDeletedSuccess"), AlertVariant.success); - } catch (error) { - addError("users:userDeletedError", error); - } - }, - }); - - const StatusRow = (user: BruteUser) => { - return ( - <> - {!user.enabled && ( - - )} - {user.bruteForceStatus?.disabled && ( - - )} - {user.enabled && !user.bruteForceStatus?.disabled && "—"} - - ); - }; - - const ValidatedEmail = (user: UserRepresentation) => { - return ( - <> - {!user.emailVerified && ( - {t("notVerified")}} - > - - - )}{" "} - {emptyFormatter()(user.email)} - - ); - }; - - const goToCreate = () => navigate(toAddUser({ realm: realmName })); - - if (!userStorage || !realm) { - return ; - } - - //should *only* list users when no user federation is configured - const listUsers = !(userStorage.length > 0); - - const toolbar = ( - <> - - - - {!realm.bruteForceProtected ? ( - - - - ) : ( - - setKebabOpen(open)} />} - isOpen={kebabOpen} - isPlain - dropdownItems={[ - { - toggleDeleteDialog(); - setKebabOpen(false); - }} - > - {t("deleteUser")} - , - - { - toggleUnlockUsersDialog(); - setKebabOpen(false); - }} - > - {t("unlockAllUsers")} - , - ]} - /> - - )} - - ); - return ( <> - - {t("userList")}} {...listTab} > - setSelectedRows([...rows])} - emptyState={ - !listUsers ? ( - <> - - - - - { - setSearchUser(value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - refresh(); - } - }} - /> - - - - {toolbar} - - - - - {t("searchForUserDescription")} - - - - ) : ( - - ) - } - toolbarItem={isManager ? toolbar : undefined} - actionResolver={(rowData: IRowData) => { - const user: UserRepresentation = rowData.data; - if (!user.access?.manage) return []; - - return [ - { - title: t("common:delete"), - onClick: () => { - setSelectedRows([user]); - toggleDeleteDialog(); - }, - }, - ]; - }} - columns={[ - { - name: "username", - displayKey: "users:username", - cellRenderer: UserDetailLink, - }, - { - name: "email", - displayKey: "users:email", - cellRenderer: ValidatedEmail, - }, - { - name: "lastName", - displayKey: "users:lastName", - cellFormatters: [emptyFormatter()], - }, - { - name: "firstName", - displayKey: "users:firstName", - cellFormatters: [emptyFormatter()], - }, - { - name: "status", - displayKey: "users:status", - cellRenderer: StatusRow, - }, - ]} - /> + {isFeatureEnabled(Feature.AdminFineGrainedAuthz) && (