Users: Add groups tab and list groups (#450)

* usergroups call wip

* user groups

* add user groups tab and list group data

* clean up log stmts

* add cypress test

* clean up userGroups

* remove comment

* fix types

* cypress test

* fix lint and cypress test

* lint

* address PR feedback from Mark

* clean up

* remove component from viewheader

* rebase and format

* remove duplicate identifier

* wrap groups in section

* fix ts errors

* add search functionality

* remove comment

* list groups initially

* remove log stmt
This commit is contained in:
Eugenia 2021-03-23 15:02:27 -04:00 committed by GitHub
parent 6c4aa0b100
commit 236e89dc63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 250 additions and 8 deletions

View file

@ -62,6 +62,12 @@ describe("Users test", () => {
masthead.checkNotificationMessage("The user has been saved");
cy.wait(1000);
// Go to user details
cy.getId("user-groups-tab").click();
sidebarPage.goToUsers();
listingPage.searchItem(itemId).itemExist(itemId);
@ -75,5 +81,6 @@ describe("Users test", () => {
listingPage.itemExist(itemId, false);
});
});
});

View file

@ -43,6 +43,7 @@ export const RealmRoleTabs = () => {
const [role, setRole] = useState<RoleFormType>();
const { id, clientId } = useParams<{ id: string; clientId: string }>();
const { url } = useRouteMatch();
const { realm } = useRealm();

View file

@ -35,7 +35,7 @@ export const RealmRolesSection = () => {
params.search = searchParam;
}
if (listRoles) {
if (!listRoles && !searchParam) {
return [];
}

View file

@ -22,6 +22,7 @@ import { UsersSection } from "./user/UsersSection";
import { MappingDetails } from "./client-scopes/details/MappingDetails";
import { ClientDetails } from "./clients/ClientDetails";
import { UsersTabs } from "./user/UsersTabs";
import { UserGroups } from "./user/UserGroups";
import { UserFederationKerberosSettings } from "./user-federation/UserFederationKerberosSettings";
import { UserFederationLdapSettings } from "./user-federation/UserFederationLdapSettings";
import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm";
@ -145,6 +146,12 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("users:createUser"),
access: "manage-users",
},
{
path: "/:realm/users/:id",
component: UserGroups,
breadcrumb: t("users:userDetails"),
access: "manage-users",
},
{
path: "/:realm/users/:id/:tab",
component: UsersTabs,

View file

@ -49,9 +49,9 @@ export const UserForm = ({
useEffect(() => {
if (editMode) {
return asyncStateFetch(
() => adminClient.users.find({ username: id }),
() => adminClient.users.findOne({ id: id }),
(user) => {
setupForm(user[0]);
setupForm(user);
},
handleError
);

193
src/user/UserGroups.tsx Normal file
View file

@ -0,0 +1,193 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
Button,
ButtonVariant,
Checkbox,
PageSection,
} from "@patternfly/react-core";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { emptyFormatter } from "../util";
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { cellWidth } from "@patternfly/react-table";
import { useErrorHandler } from "react-error-boundary";
export const UserGroups = () => {
const { t } = useTranslation("roles");
const { addAlert } = useAlerts();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const handleError = useErrorHandler();
const [selectedGroup, setSelectedGroup] = useState<GroupRepresentation>();
const [listGroups, setListGroups] = useState(true);
const [search, setSearch] = useState("");
const [username, setUsername] = useState("");
const [isDirectMembership, setDirectMembership] = useState(false);
const [open, setOpen] = useState(false);
const adminClient = useAdminClient();
const { id } = useParams<{ id: string }>();
const loader = async (first?: number, max?: number, search?: string) => {
const params: { [name: string]: string | number } = {
first: first!,
max: max!,
};
const user = await adminClient.users.findOne({ id });
setUsername(user.username!);
const searchParam = search || "";
if (searchParam) {
params.search = searchParam;
setSearch(searchParam);
}
if (!searchParam && !listGroups) {
return [];
}
return await adminClient.users.listGroups({ ...params, id });
};
useEffect(() => {
return asyncStateFetch(
() => {
return Promise.resolve(adminClient.users.listGroups({ id }));
},
(response) => {
setListGroups(!!(response && response.length > 0));
},
handleError
);
});
useEffect(() => {
refresh();
}, [isDirectMembership]);
const AliasRenderer = (group: GroupRepresentation) => {
return <>{group.name}</>;
};
const LeaveButtonRenderer = (group: GroupRepresentation) => {
return (
<>
<Button onClick={() => leave(group)} variant="link">
{t("users:Leave")}
</Button>
</>
);
};
const toggleModal = () => setOpen(!open);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("users:leaveGroup", {
name: selectedGroup?.name,
}),
messageKey: t("users:leaveGroupConfirmDialog", {
groupname: selectedGroup?.name,
username: username,
}),
continueButtonLabel: "users:leave",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.users.delFromGroup({
id,
groupId: selectedGroup!.id!,
});
refresh();
addAlert(t("users:removedGroupMembership"), AlertVariant.success);
} catch (error) {
addAlert(
t("users:removedGroupMembershipError", { error }),
AlertVariant.danger
);
}
},
});
const leave = (group: GroupRepresentation) => {
setSelectedGroup(group);
toggleDeleteDialog();
};
return (
<>
<PageSection variant="light">
<DeleteConfirm />
<KeycloakDataTable
key={key}
loader={loader}
isPaginated
ariaLabelKey="roles:roleList"
searchPlaceholderKey="groups:searchGroup"
canSelectAll
onSelect={() => {}}
toolbarItem={
<>
<Button
className="kc-join-group-button"
key="join-group-button"
onClick={() => toggleModal()}
data-testid="add-group-button"
>
{t("users:joinGroup")}
</Button>
<Checkbox
label={t("users:directMembership")}
key="direct-membership-check"
id="kc-direct-membership-checkbox"
onChange={() => setDirectMembership(!isDirectMembership)}
isChecked={isDirectMembership}
/>
</>
}
columns={[
{
name: "groupMembership",
displayKey: "users:groupMembership",
cellRenderer: AliasRenderer,
cellFormatters: [emptyFormatter()],
transforms: [cellWidth(40)],
},
{
name: "path",
displayKey: "users:Path",
cellFormatters: [emptyFormatter()],
transforms: [cellWidth(45)],
},
{
name: "",
cellRenderer: LeaveButtonRenderer,
cellFormatters: [emptyFormatter()],
transforms: [cellWidth(20)],
},
]}
emptyState={
!search ? (
<ListEmptyState
hasIcon={true}
message={t("users:noGroups")}
instructions={t("users:noGroupsText")}
primaryActionText={t("users:joinGroup")}
onPrimaryAction={() => {}}
/>
) : (
""
)
}
/>
</PageSection>
</>
);
};

View file

@ -74,7 +74,7 @@ export const UsersSection = () => {
const UserDetailLink = (user: UserRepresentation) => (
<>
<Link key={user.username} to={`${url}/${user.username}/details`}>
<Link key={user.username} to={`${url}/${user.id}/details`}>
{user.username}
</Link>
</>

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
import {
AlertVariant,
PageSection,
@ -15,6 +15,7 @@ import { useAlerts } from "../components/alert/Alerts";
import { useAdminClient } from "../context/auth/AdminClient";
import { useHistory, useParams, useRouteMatch } from "react-router-dom";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { UserGroups } from "./UserGroups";
export const UsersTabs = () => {
const { t } = useTranslation("roles");
@ -25,6 +26,17 @@ export const UsersTabs = () => {
const adminClient = useAdminClient();
const form = useForm<UserRepresentation>({ mode: "onChange" });
const { id } = useParams<{ id: string }>();
const [user, setUser] = useState("");
useEffect(() => {
const update = async () => {
if (id) {
const fetchedUser = await adminClient.users.findOne({ id });
setUser(fetchedUser.username!);
}
};
setTimeout(update, 100);
}, []);
const save = async (user: UserRepresentation) => {
try {
@ -48,7 +60,7 @@ export const UsersTabs = () => {
return (
<>
<ViewHeader titleKey={id! || t("users:createUser")} subKey="" />
<ViewHeader titleKey={user! || t("users:createUser")} subKey="" />
<PageSection variant="light">
{id && (
<KeycloakTabs isBox>
@ -59,6 +71,13 @@ export const UsersTabs = () => {
>
<UserForm form={form} save={save} editMode={true} />
</Tab>
<Tab
eventKey="groups"
data-testid="user-groups-tab"
title={<TabTitleText>{t("groups")}</TabTitleText>}
>
<UserGroups />
</Tab>
</KeycloakTabs>
)}
{!id && <UserForm form={form} save={save} editMode={false} />}

View file

@ -7,6 +7,17 @@
"createNewUser": "Create new user",
"noUsersFound": "No users found",
"noUsersFoundError": "No users found due to {{error}}",
"noGroups": "No groups",
"noGroupsText": "You haven't added this user to any groups. Join a group to get started.",
"joinGroup": "Join Group",
"leave": "Leave",
"leaveGroup": "Leave group {{name}}?",
"leaveGroupConfirmDialog": "Are you sure you want to remove {{username}} from the group {{groupname}}?",
"directMembership": "Direct membership",
"groupMembership": "Group membership",
"removedGroupMembership": "Removed group membership",
"removedGroupMembershipError": "Error removing group membership",
"path": "Path",
"emptyInstructions": "Change your search criteria or add a user",
"id": "ID",
"createdAt": "Created at",

View file

@ -1,4 +1,8 @@
.keycloak__user-section__email-verified {
color: var(--pf-global--danger-color--100);
}
}
button.pf-c-button.pf-m-primary.kc-join-group-button {
margin-left: var(--pf-global--spacer--md);
margin-right: var(--pf-global--spacer--xl);
}