Use React Router v6 for the routable tabs of user details (#4114)

This commit is contained in:
Jon Koops 2023-01-03 17:15:04 +01:00 committed by GitHub
parent b5e9eb4620
commit 5032770ddb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 367 additions and 315 deletions

View file

@ -11,8 +11,7 @@ import {
JSXElementConstructor, JSXElementConstructor,
ReactElement, ReactElement,
} from "react"; } from "react";
import type { Path } from "react-router-dom-v5-compat"; import { Path, useHref, useLocation } from "react-router-dom-v5-compat";
import { useLocation } from "react-router-dom-v5-compat";
// TODO: Remove the custom 'children' props and type once the following issue has been resolved: // TODO: Remove the custom 'children' props and type once the following issue has been resolved:
// https://github.com/patternfly/patternfly-react/issues/6766 // https://github.com/patternfly/patternfly-react/issues/6766
@ -76,3 +75,8 @@ export const routableTab = ({ to, history }: RoutableTabParams) => ({
eventKey: to.pathname ?? "", eventKey: to.pathname ?? "",
href: history.createHref(to), href: history.createHref(to),
}); });
export const useRoutableTab = (to: Partial<Path>) => ({
eventKey: to.pathname ?? "",
href: useHref(to),
});

View file

@ -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<UserRepresentation>({ mode: "onChange" });
const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]);
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 (
<>
<ViewHeader
titleKey={t("createUser")}
className="kc-username-view-header"
/>
<PageSection variant="light" className="pf-u-p-0">
<UserProfileProvider>
<FormProvider {...userForm}>
<PageSection variant="light">
<UserForm onGroupsUpdate={setAddedGroups} save={save} />
</PageSection>
</FormProvider>
</UserProfileProvider>
</PageSection>
</>
);
}

View file

@ -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<UserParams>();
const { t } = useTranslation("users");
const [user, setUser] = useState<UserRepresentation>();
const [bruteForced, setBruteForced] = useState<BruteForced>();
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 <KeycloakSpinner />;
}
return (
<EditUserForm user={user} bruteForced={bruteForced} refresh={refresh} />
);
}
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<UserRepresentation>({
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 (
<>
<ImpersonateConfirm />
<DeleteConfirm />
<ViewHeader
titleKey={user.username!}
className="kc-username-view-header"
divider={false}
dropdownItems={[
<DropdownItem
key="impersonate"
isDisabled={!user.access?.impersonate}
onClick={() => toggleImpersonateDialog()}
>
{t("impersonate")}
</DropdownItem>,
<DropdownItem
key="delete"
isDisabled={!user.access?.manage}
onClick={() => toggleDeleteDialog()}
>
{t("common:delete")}
</DropdownItem>,
]}
/>
<PageSection variant="light" className="pf-u-p-0">
<UserProfileProvider>
<FormProvider {...userForm}>
<RoutableTabs
isBox
mountOnEnter
defaultLocation={toTab("settings")}
>
<Tab
data-testid="user-details-tab"
title={<TabTitleText>{t("common:details")}</TabTitleText>}
{...settingsTab}
>
<PageSection variant="light">
<UserForm save={save} user={user} bruteForce={bruteForced} />
</PageSection>
</Tab>
<Tab
data-testid="attributes"
title={<TabTitleText>{t("common:attributes")}</TabTitleText>}
{...attributesTab}
>
<UserAttributes user={user} />
</Tab>
<Tab
data-testid="credentials"
isHidden={!user.access?.manage}
title={<TabTitleText>{t("common:credentials")}</TabTitleText>}
{...credentialsTab}
>
<UserCredentials user={user} />
</Tab>
<Tab
data-testid="role-mapping-tab"
isHidden={!user.access?.mapRoles}
title={<TabTitleText>{t("roleMapping")}</TabTitleText>}
{...roleMappingTab}
>
<UserRoleMapping id={user.id!} name={user.username!} />
</Tab>
<Tab
data-testid="user-groups-tab"
title={<TabTitleText>{t("common:groups")}</TabTitleText>}
{...groupsTab}
>
<UserGroups user={user} />
</Tab>
<Tab
data-testid="user-consents-tab"
title={<TabTitleText>{t("consents")}</TabTitleText>}
{...consentsTab}
>
<UserConsents />
</Tab>
{hasAccess("view-identity-providers") && (
<Tab
data-testid="identity-provider-links-tab"
title={
<TabTitleText>{t("identityProviderLinks")}</TabTitleText>
}
{...identityProviderLinksTab}
>
<UserIdentityProviderLinks userId={user.id!} />
</Tab>
)}
<Tab
data-testid="user-sessions-tab"
title={<TabTitleText>{t("sessions")}</TabTitleText>}
{...sessionsTab}
>
<UserSessions />
</Tab>
</RoutableTabs>
</FormProvider>
</UserProfileProvider>
</PageSection>
</>
);
};

View file

@ -42,7 +42,7 @@ export type UserFormProps = {
user?: UserRepresentation; user?: UserRepresentation;
bruteForce?: BruteForced; bruteForce?: BruteForced;
save: (user: UserRepresentation) => void; save: (user: UserRepresentation) => void;
onGroupsUpdate: (groups: GroupRepresentation[]) => void; onGroupsUpdate?: (groups: GroupRepresentation[]) => void;
}; };
export const UserForm = ({ export const UserForm = ({
@ -119,12 +119,12 @@ export const UserForm = ({
const deleteItem = (id: string) => { const deleteItem = (id: string) => {
setSelectedGroups(selectedGroups.filter((item) => item.name !== id)); setSelectedGroups(selectedGroups.filter((item) => item.name !== id));
onGroupsUpdate(selectedGroups); onGroupsUpdate?.(selectedGroups);
}; };
const addChips = async (groups: GroupRepresentation[]): Promise<void> => { const addChips = async (groups: GroupRepresentation[]): Promise<void> => {
setSelectedGroups([...selectedGroups!, ...groups]); setSelectedGroups([...selectedGroups!, ...groups]);
onGroupsUpdate([...selectedGroups!, ...groups]); onGroupsUpdate?.([...selectedGroups!, ...groups]);
}; };
const addGroups = async (groups: GroupRepresentation[]): Promise<void> => { const addGroups = async (groups: GroupRepresentation[]): Promise<void> => {
@ -171,6 +171,22 @@ export const UserForm = ({
filterGroups={selectedGroups} filterGroups={selectedGroups}
/> />
)} )}
<FormGroup label={t("common:enabled")} fieldId="kc-user-enabled">
<Controller
name="enabled"
defaultValue={true}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-user-enabled"
onChange={(value) => onChange(value)}
isChecked={value}
label={t("common:yes")}
labelOff={t("common:no")}
/>
)}
/>
</FormGroup>
{user?.id && ( {user?.id && (
<> <>
<FormGroup label={t("common:id")} fieldId="kc-id" isRequired> <FormGroup label={t("common:id")} fieldId="kc-id" isRequired>
@ -321,8 +337,8 @@ export const UserForm = ({
isDisabled={false} isDisabled={false}
onChange={(value) => onChange(value)} onChange={(value) => onChange(value)}
isChecked={value} isChecked={value}
label={t("common:on")} label={t("common:yes")}
labelOff={t("common:off")} labelOff={t("common:no")}
/> />
)} )}
/> />

View file

@ -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<UserRepresentation>({ mode: "onChange" });
const { id } = useParams<UserParams>();
const [user, setUser] = useState<UserRepresentation>();
const [bruteForced, setBruteForced] = useState<BruteForced>();
const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]);
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 <KeycloakSpinner />;
}
const toTab = (tab: UserTab) =>
toUser({
realm,
id,
tab,
});
const routableUserTab = (tab: UserTab) =>
routableTab({ history, to: toTab(tab) });
return (
<>
<ImpersonateConfirm />
<DeleteConfirm />
<Controller
name="enabled"
control={userForm.control}
defaultValue={true}
render={({ onChange, value }) => (
<ViewHeader
titleKey={user?.id ? user.username! : t("createUser")}
className="kc-username-view-header"
divider={!id}
dropdownItems={[
<DropdownItem
key="impersonate"
isDisabled={!user?.access?.impersonate}
onClick={() => toggleImpersonateDialog()}
>
{t("impersonate")}
</DropdownItem>,
<DropdownItem
key="delete"
isDisabled={!user?.access?.manage}
onClick={() => toggleDeleteDialog()}
>
{t("common:delete")}
</DropdownItem>,
]}
isEnabled={value}
onToggle={(value) => {
onChange(value);
save(userForm.getValues());
}}
/>
)}
/>
<PageSection variant="light" className="pf-u-p-0">
<UserProfileProvider>
<FormProvider {...userForm}>
{id && user && (
<RoutableTabs
isBox
mountOnEnter
defaultLocation={toTab("settings")}
>
<Tab
data-testid="user-details-tab"
title={<TabTitleText>{t("common:details")}</TabTitleText>}
{...routableUserTab("settings")}
>
<PageSection variant="light">
{bruteForced && (
<UserForm
onGroupsUpdate={updateGroups}
save={save}
user={user}
bruteForce={bruteForced}
/>
)}
</PageSection>
</Tab>
<Tab
data-testid="attributes"
title={<TabTitleText>{t("common:attributes")}</TabTitleText>}
{...routableUserTab("attributes")}
>
<UserAttributes user={user} />
</Tab>
<Tab
data-testid="credentials"
isHidden={!user.access?.manage}
title={<TabTitleText>{t("common:credentials")}</TabTitleText>}
{...routableUserTab("credentials")}
>
<UserCredentials user={user} />
</Tab>
<Tab
data-testid="role-mapping-tab"
isHidden={!user.access?.mapRoles}
title={<TabTitleText>{t("roleMapping")}</TabTitleText>}
{...routableUserTab("role-mapping")}
>
<UserRoleMapping id={id} name={user.username!} />
</Tab>
<Tab
data-testid="user-groups-tab"
title={<TabTitleText>{t("common:groups")}</TabTitleText>}
{...routableUserTab("groups")}
>
<UserGroups user={user} />
</Tab>
<Tab
data-testid="user-consents-tab"
title={<TabTitleText>{t("consents")}</TabTitleText>}
{...routableUserTab("consents")}
>
<UserConsents />
</Tab>
{hasAccess("view-identity-providers") && (
<Tab
data-testid="identity-provider-links-tab"
title={
<TabTitleText>{t("identityProviderLinks")}</TabTitleText>
}
{...routableUserTab("identity-provider-links")}
>
<UserIdentityProviderLinks userId={id} />
</Tab>
)}
<Tab
data-testid="user-sessions-tab"
title={<TabTitleText>{t("sessions")}</TabTitleText>}
{...routableUserTab("sessions")}
>
<UserSessions />
</Tab>
</RoutableTabs>
)}
{!id && (
<PageSection variant="light">
<UserForm onGroupsUpdate={updateGroups} save={save} />
</PageSection>
)}
</FormProvider>
</UserProfileProvider>
</PageSection>
</>
);
};
export default UsersTabs;

View file

@ -8,7 +8,7 @@ export type AddUserParams = { realm: string };
export const AddUserRoute: RouteDef = { export const AddUserRoute: RouteDef = {
path: "/:realm/users/add-user", path: "/:realm/users/add-user",
component: lazy(() => import("../UsersTabs")), component: lazy(() => import("../CreateUser")),
breadcrumb: (t) => t("users:createUser"), breadcrumb: (t) => t("users:createUser"),
access: ["query-users", "query-groups"], access: ["query-users", "query-groups"],
}; };

View file

@ -21,7 +21,7 @@ export type UserParams = {
export const UserRoute: RouteDef = { export const UserRoute: RouteDef = {
path: "/:realm/users/:id/:tab", path: "/:realm/users/:id/:tab",
component: lazy(() => import("../UsersTabs")), component: lazy(() => import("../EditUser")),
breadcrumb: (t) => t("users:userDetails"), breadcrumb: (t) => t("users:userDetails"),
access: "query-users", access: "query-users",
}; };