From 5032770ddb58fe107c477d3f6ca15dbf47e1db81 Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Tue, 3 Jan 2023 17:15:04 +0100 Subject: [PATCH] Use React Router v6 for the routable tabs of user details (#4114) --- .../components/routable-tabs/RoutableTabs.tsx | 8 +- apps/admin-ui/src/user/CreateUser.tsx | 60 ++++ apps/admin-ui/src/user/EditUser.tsx | 278 ++++++++++++++++ apps/admin-ui/src/user/UserForm.tsx | 26 +- apps/admin-ui/src/user/UsersTabs.tsx | 306 ------------------ apps/admin-ui/src/user/routes/AddUser.ts | 2 +- apps/admin-ui/src/user/routes/User.ts | 2 +- 7 files changed, 367 insertions(+), 315 deletions(-) create mode 100644 apps/admin-ui/src/user/CreateUser.tsx create mode 100644 apps/admin-ui/src/user/EditUser.tsx delete mode 100644 apps/admin-ui/src/user/UsersTabs.tsx diff --git a/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx b/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx index a7474d1b8d..620cbf6d1d 100644 --- a/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx +++ b/apps/admin-ui/src/components/routable-tabs/RoutableTabs.tsx @@ -11,8 +11,7 @@ import { JSXElementConstructor, ReactElement, } from "react"; -import type { Path } from "react-router-dom-v5-compat"; -import { useLocation } from "react-router-dom-v5-compat"; +import { Path, useHref, useLocation } from "react-router-dom-v5-compat"; // TODO: Remove the custom 'children' props and type once the following issue has been resolved: // https://github.com/patternfly/patternfly-react/issues/6766 @@ -76,3 +75,8 @@ export const routableTab = ({ to, history }: RoutableTabParams) => ({ eventKey: to.pathname ?? "", href: history.createHref(to), }); + +export const useRoutableTab = (to: Partial) => ({ + eventKey: to.pathname ?? "", + href: useHref(to), +}); diff --git a/apps/admin-ui/src/user/CreateUser.tsx b/apps/admin-ui/src/user/CreateUser.tsx new file mode 100644 index 0000000000..b12113c7b7 --- /dev/null +++ b/apps/admin-ui/src/user/CreateUser.tsx @@ -0,0 +1,60 @@ +import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import { AlertVariant, PageSection } from "@patternfly/react-core"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom-v5-compat"; + +import { useAlerts } from "../components/alert/Alerts"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +import { useAdminClient } from "../context/auth/AdminClient"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext"; +import { toUser } from "./routes/User"; +import { UserForm } from "./UserForm"; + +import "./user-section.css"; + +export default function CreateUser() { + const { t } = useTranslation("users"); + const { addAlert, addError } = useAlerts(); + const navigate = useNavigate(); + const { adminClient } = useAdminClient(); + const { realm } = useRealm(); + const userForm = useForm({ mode: "onChange" }); + const [addedGroups, setAddedGroups] = useState([]); + + const save = async (formUser: UserRepresentation) => { + try { + const createdUser = await adminClient.users.create({ + ...formUser, + username: formUser.username?.trim(), + groups: addedGroups.map((group) => group.path!), + }); + + addAlert(t("userCreated"), AlertVariant.success); + navigate(toUser({ id: createdUser.id, realm, tab: "settings" })); + } catch (error) { + addError("users:userCreateError", error); + } + }; + + return ( + <> + + + + + + + + + + + + ); +} diff --git a/apps/admin-ui/src/user/EditUser.tsx b/apps/admin-ui/src/user/EditUser.tsx new file mode 100644 index 0000000000..a4d9a2618a --- /dev/null +++ b/apps/admin-ui/src/user/EditUser.tsx @@ -0,0 +1,278 @@ +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import { + AlertVariant, + ButtonVariant, + DropdownItem, + PageSection, + Tab, + TabTitleText, +} from "@patternfly/react-core"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom-v5-compat"; + +import { useAlerts } from "../components/alert/Alerts"; +import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; +import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; +import { + RoutableTabs, + useRoutableTab, +} from "../components/routable-tabs/RoutableTabs"; +import { ViewHeader } from "../components/view-header/ViewHeader"; +import { useAccess } from "../context/access/Access"; +import { useAdminClient, useFetch } from "../context/auth/AdminClient"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext"; +import { useParams } from "../utils/useParams"; +import { toUser, UserParams, UserTab } from "./routes/User"; +import { toUsers } from "./routes/Users"; +import { UserAttributes } from "./UserAttributes"; +import { UserConsents } from "./UserConsents"; +import { UserCredentials } from "./UserCredentials"; +import { BruteForced, UserForm } from "./UserForm"; +import { UserGroups } from "./UserGroups"; +import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks"; +import { UserRoleMapping } from "./UserRoleMapping"; +import { UserSessions } from "./UserSessions"; + +import "./user-section.css"; + +export default function EditUser() { + const { adminClient } = useAdminClient(); + const { realm } = useRealm(); + const { id } = useParams(); + const { t } = useTranslation("users"); + const [user, setUser] = useState(); + const [bruteForced, setBruteForced] = useState(); + const [refreshCount, setRefreshCount] = useState(0); + const refresh = () => setRefreshCount((count) => count + 1); + + useFetch( + async () => { + const [user, currentRealm, attackDetection] = await Promise.all([ + adminClient.users.findOne({ id: id! }), + adminClient.realms.findOne({ realm }), + adminClient.attackDetection.findOne({ id: id! }), + ]); + + if (!user || !currentRealm || !attackDetection) { + throw new Error(t("common:notFound")); + } + + const isBruteForceProtected = currentRealm.bruteForceProtected; + const isLocked = isBruteForceProtected && attackDetection.disabled; + + return { user, bruteForced: { isBruteForceProtected, isLocked } }; + }, + ({ user, bruteForced }) => { + setUser(user); + setBruteForced(bruteForced); + }, + [refreshCount] + ); + + if (!user || !bruteForced) { + return ; + } + + return ( + + ); +} + +type EditUserFormProps = { + user: UserRepresentation; + bruteForced: BruteForced; + refresh: () => void; +}; + +const EditUserForm = ({ user, bruteForced, refresh }: EditUserFormProps) => { + const { t } = useTranslation("users"); + const { realm } = useRealm(); + const { adminClient } = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const navigate = useNavigate(); + const { hasAccess } = useAccess(); + const userForm = useForm({ + mode: "onChange", + defaultValues: user, + }); + + const toTab = (tab: UserTab) => + toUser({ + realm, + id: user.id!, + tab, + }); + + const useTab = (tab: UserTab) => useRoutableTab(toTab(tab)); + + const settingsTab = useTab("settings"); + const attributesTab = useTab("attributes"); + const credentialsTab = useTab("credentials"); + const roleMappingTab = useTab("role-mapping"); + const groupsTab = useTab("groups"); + const consentsTab = useTab("consents"); + const identityProviderLinksTab = useTab("identity-provider-links"); + const sessionsTab = useTab("sessions"); + + const save = async (formUser: UserRepresentation) => { + try { + await adminClient.users.update( + { id: user.id! }, + { + ...formUser, + username: formUser.username?.trim(), + attributes: { ...user.attributes, ...formUser.attributes }, + } + ); + addAlert(t("userSaved"), AlertVariant.success); + refresh(); + } catch (error) { + addError("users:userCreateError", error); + } + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "users:deleteConfirm", + messageKey: "users:deleteConfirmCurrentUser", + continueButtonLabel: "common:delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + await adminClient.users.del({ id: user.id! }); + addAlert(t("userDeletedSuccess"), AlertVariant.success); + navigate(toUsers({ realm })); + } catch (error) { + addError("users:userDeletedError", error); + } + }, + }); + + const [toggleImpersonateDialog, ImpersonateConfirm] = useConfirmDialog({ + titleKey: "users:impersonateConfirm", + messageKey: "users:impersonateConfirmDialog", + continueButtonLabel: "users:impersonate", + onConfirm: async () => { + try { + const data = await adminClient.users.impersonation( + { id: user.id! }, + { user: user.id!, realm } + ); + if (data.sameRealm) { + window.location = data.redirect; + } else { + window.open(data.redirect, "_blank"); + } + } catch (error) { + addError("users:impersonateError", error); + } + }, + }); + + return ( + <> + + + toggleImpersonateDialog()} + > + {t("impersonate")} + , + toggleDeleteDialog()} + > + {t("common:delete")} + , + ]} + /> + + + + + + {t("common:details")}} + {...settingsTab} + > + + + + + {t("common:attributes")}} + {...attributesTab} + > + + + {t("common:credentials")}} + {...credentialsTab} + > + + + {t("roleMapping")}} + {...roleMappingTab} + > + + + {t("common:groups")}} + {...groupsTab} + > + + + {t("consents")}} + {...consentsTab} + > + + + {hasAccess("view-identity-providers") && ( + {t("identityProviderLinks")} + } + {...identityProviderLinksTab} + > + + + )} + {t("sessions")}} + {...sessionsTab} + > + + + + + + + + ); +}; diff --git a/apps/admin-ui/src/user/UserForm.tsx b/apps/admin-ui/src/user/UserForm.tsx index 6b97288a90..a9d8ea4128 100644 --- a/apps/admin-ui/src/user/UserForm.tsx +++ b/apps/admin-ui/src/user/UserForm.tsx @@ -42,7 +42,7 @@ export type UserFormProps = { user?: UserRepresentation; bruteForce?: BruteForced; save: (user: UserRepresentation) => void; - onGroupsUpdate: (groups: GroupRepresentation[]) => void; + onGroupsUpdate?: (groups: GroupRepresentation[]) => void; }; export const UserForm = ({ @@ -119,12 +119,12 @@ export const UserForm = ({ const deleteItem = (id: string) => { setSelectedGroups(selectedGroups.filter((item) => item.name !== id)); - onGroupsUpdate(selectedGroups); + onGroupsUpdate?.(selectedGroups); }; const addChips = async (groups: GroupRepresentation[]): Promise => { setSelectedGroups([...selectedGroups!, ...groups]); - onGroupsUpdate([...selectedGroups!, ...groups]); + onGroupsUpdate?.([...selectedGroups!, ...groups]); }; const addGroups = async (groups: GroupRepresentation[]): Promise => { @@ -171,6 +171,22 @@ export const UserForm = ({ filterGroups={selectedGroups} /> )} + + ( + onChange(value)} + isChecked={value} + label={t("common:yes")} + labelOff={t("common:no")} + /> + )} + /> + {user?.id && ( <> @@ -321,8 +337,8 @@ export const UserForm = ({ isDisabled={false} onChange={(value) => onChange(value)} isChecked={value} - label={t("common:on")} - labelOff={t("common:off")} + label={t("common:yes")} + labelOff={t("common:no")} /> )} /> diff --git a/apps/admin-ui/src/user/UsersTabs.tsx b/apps/admin-ui/src/user/UsersTabs.tsx deleted file mode 100644 index 58c2a5aaea..0000000000 --- a/apps/admin-ui/src/user/UsersTabs.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; -import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; -import { - AlertVariant, - ButtonVariant, - DropdownItem, - PageSection, - Tab, - TabTitleText, -} from "@patternfly/react-core"; -import { useState } from "react"; -import { Controller, FormProvider, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { useHistory } from "react-router-dom"; -import { useNavigate } from "react-router-dom-v5-compat"; - -import { useAlerts } from "../components/alert/Alerts"; -import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; -import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; -import { - RoutableTabs, - routableTab, -} from "../components/routable-tabs/RoutableTabs"; -import { ViewHeader } from "../components/view-header/ViewHeader"; -import { useAccess } from "../context/access/Access"; -import { useAdminClient, useFetch } from "../context/auth/AdminClient"; -import { useRealm } from "../context/realm-context/RealmContext"; -import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext"; -import { useParams } from "../utils/useParams"; -import { toUser, UserParams, UserTab } from "./routes/User"; -import { toUsers } from "./routes/Users"; -import { UserAttributes } from "./UserAttributes"; -import { UserConsents } from "./UserConsents"; -import { UserCredentials } from "./UserCredentials"; -import { BruteForced, UserForm } from "./UserForm"; -import { UserGroups } from "./UserGroups"; -import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks"; -import { UserRoleMapping } from "./UserRoleMapping"; -import { UserSessions } from "./UserSessions"; - -import "./user-section.css"; - -const UsersTabs = () => { - const { t } = useTranslation("users"); - const { addAlert, addError } = useAlerts(); - const navigate = useNavigate(); - const { realm } = useRealm(); - const { hasAccess } = useAccess(); - const history = useHistory(); - - const { adminClient } = useAdminClient(); - const userForm = useForm({ mode: "onChange" }); - const { id } = useParams(); - const [user, setUser] = useState(); - const [bruteForced, setBruteForced] = useState(); - const [addedGroups, setAddedGroups] = useState([]); - const [refreshCount, setRefreshCount] = useState(0); - const refresh = () => setRefreshCount((count) => count + 1); - - useFetch( - async () => { - if (id) { - const user = await adminClient.users.findOne({ id }); - if (!user) { - throw new Error(t("common:notFound")); - } - - const isBruteForceProtected = (await adminClient.realms.findOne({ - realm, - }))!.bruteForceProtected; - const bruteForce = await adminClient.attackDetection.findOne({ - id: user.id!, - }); - const isLocked: boolean = - isBruteForceProtected && bruteForce && bruteForce.disabled; - return { user, bruteForced: { isBruteForceProtected, isLocked } }; - } - return { user: undefined }; - }, - ({ user, bruteForced }) => { - setUser(user); - setBruteForced(bruteForced); - user && setupForm(user); - }, - [user?.username, refreshCount] - ); - - const setupForm = (user: UserRepresentation) => { - userForm.reset(user); - }; - - const updateGroups = (groups: GroupRepresentation[]) => { - setAddedGroups(groups); - }; - - const save = async (formUser: UserRepresentation) => { - formUser.username = formUser.username?.trim(); - - try { - if (id) { - await adminClient.users.update( - { id }, - { - ...formUser, - attributes: { ...user?.attributes, ...formUser.attributes }, - } - ); - addAlert(t("userSaved"), AlertVariant.success); - refresh(); - } else { - const createdUser = await adminClient.users.create({ - ...formUser, - groups: addedGroups.map((group) => group.path!), - }); - - addAlert(t("userCreated"), AlertVariant.success); - navigate(toUser({ id: createdUser.id, realm, tab: "settings" })); - } - } catch (error) { - addError("users:userCreateError", error); - } - }; - - const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ - titleKey: "users:deleteConfirm", - messageKey: "users:deleteConfirmCurrentUser", - continueButtonLabel: "common:delete", - continueButtonVariant: ButtonVariant.danger, - onConfirm: async () => { - try { - await adminClient.users.del({ id }); - addAlert(t("userDeletedSuccess"), AlertVariant.success); - navigate(toUsers({ realm })); - } catch (error) { - addError("users:userDeletedError", error); - } - }, - }); - - const [toggleImpersonateDialog, ImpersonateConfirm] = useConfirmDialog({ - titleKey: "users:impersonateConfirm", - messageKey: "users:impersonateConfirmDialog", - continueButtonLabel: "users:impersonate", - onConfirm: async () => { - try { - const data = await adminClient.users.impersonation( - { id }, - { user: id, realm } - ); - if (data.sameRealm) { - window.location = data.redirect; - } else { - window.open(data.redirect, "_blank"); - } - } catch (error) { - addError("users:impersonateError", error); - } - }, - }); - - if (id && !user) { - return ; - } - - const toTab = (tab: UserTab) => - toUser({ - realm, - id, - tab, - }); - - const routableUserTab = (tab: UserTab) => - routableTab({ history, to: toTab(tab) }); - - return ( - <> - - - ( - toggleImpersonateDialog()} - > - {t("impersonate")} - , - toggleDeleteDialog()} - > - {t("common:delete")} - , - ]} - isEnabled={value} - onToggle={(value) => { - onChange(value); - save(userForm.getValues()); - }} - /> - )} - /> - - - - {id && user && ( - - {t("common:details")}} - {...routableUserTab("settings")} - > - - {bruteForced && ( - - )} - - - {t("common:attributes")}} - {...routableUserTab("attributes")} - > - - - {t("common:credentials")}} - {...routableUserTab("credentials")} - > - - - {t("roleMapping")}} - {...routableUserTab("role-mapping")} - > - - - {t("common:groups")}} - {...routableUserTab("groups")} - > - - - {t("consents")}} - {...routableUserTab("consents")} - > - - - {hasAccess("view-identity-providers") && ( - {t("identityProviderLinks")} - } - {...routableUserTab("identity-provider-links")} - > - - - )} - {t("sessions")}} - {...routableUserTab("sessions")} - > - - - - )} - {!id && ( - - - - )} - - - - - ); -}; - -export default UsersTabs; diff --git a/apps/admin-ui/src/user/routes/AddUser.ts b/apps/admin-ui/src/user/routes/AddUser.ts index f143b1161d..461f458e4a 100644 --- a/apps/admin-ui/src/user/routes/AddUser.ts +++ b/apps/admin-ui/src/user/routes/AddUser.ts @@ -8,7 +8,7 @@ export type AddUserParams = { realm: string }; export const AddUserRoute: RouteDef = { path: "/:realm/users/add-user", - component: lazy(() => import("../UsersTabs")), + component: lazy(() => import("../CreateUser")), breadcrumb: (t) => t("users:createUser"), access: ["query-users", "query-groups"], }; diff --git a/apps/admin-ui/src/user/routes/User.ts b/apps/admin-ui/src/user/routes/User.ts index 822b00c55c..8fbcb3aa87 100644 --- a/apps/admin-ui/src/user/routes/User.ts +++ b/apps/admin-ui/src/user/routes/User.ts @@ -21,7 +21,7 @@ export type UserParams = { export const UserRoute: RouteDef = { path: "/:realm/users/:id/:tab", - component: lazy(() => import("../UsersTabs")), + component: lazy(() => import("../EditUser")), breadcrumb: (t) => t("users:userDetails"), access: "query-users", };