Group details (#425)

* added details for groups

* add includeSubGroups checkbox

* added tests

* fixed reload for group attributes

* fixed spacing on associate roles tab

* fixed group reload after save

* fixed test
This commit is contained in:
Erik Jan de Wit 2021-03-16 13:37:57 +01:00 committed by GitHub
parent 518b21c6ae
commit e4d83d0fe3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 810 additions and 379 deletions

View file

@ -1,10 +1,12 @@
import ListingPage from "../support/pages/admin_console/ListingPage"; import ListingPage from "../support/pages/admin_console/ListingPage";
import CreateGroupModal from "../support/pages/admin_console/manage/groups/CreateGroupModal"; import CreateGroupModal from "../support/pages/admin_console/manage/groups/CreateGroupModal";
import GroupDetailPage from "../support/pages/admin_console/manage/groups/GroupDetailPage";
import { SearchGroupPage } from "../support/pages/admin_console/manage/groups/SearchGroup"; import { SearchGroupPage } from "../support/pages/admin_console/manage/groups/SearchGroup";
import Masthead from "../support/pages/admin_console/Masthead"; import Masthead from "../support/pages/admin_console/Masthead";
import SidebarPage from "../support/pages/admin_console/SidebarPage"; import SidebarPage from "../support/pages/admin_console/SidebarPage";
import LoginPage from "../support/pages/LoginPage"; import LoginPage from "../support/pages/LoginPage";
import ViewHeaderPage from "../support/pages/ViewHeaderPage"; import ViewHeaderPage from "../support/pages/ViewHeaderPage";
import AdminClient from "../support/util/AdminClient";
describe("Group test", () => { describe("Group test", () => {
const loginPage = new LoginPage(); const loginPage = new LoginPage();
@ -39,8 +41,6 @@ describe("Group test", () => {
// Delete // Delete
listingPage.deleteItem(groupName); listingPage.deleteItem(groupName);
masthead.checkNotificationMessage("Group deleted"); masthead.checkNotificationMessage("Group deleted");
listingPage.itemExist(groupName, false);
}); });
const searchGroupPage = new SearchGroupPage(); const searchGroupPage = new SearchGroupPage();
@ -50,4 +50,56 @@ describe("Group test", () => {
searchGroupPage.checkTerm("group"); searchGroupPage.checkTerm("group");
}); });
}); });
describe("Group details", () => {
const groups = ["level", "level1", "level2"];
const detailPage = new GroupDetailPage();
before(async () => {
const client = new AdminClient();
const createdGroups = await client.createSubGroups(groups);
for (let i = 0; i < 5; i++) {
const username = "user" + i;
client.createUserInGroup(username, createdGroups[i % 3].id);
}
});
beforeEach(() => {
cy.visit("");
loginPage.logIn();
sidebarPage.goToGroups();
});
after(() => {
new AdminClient().deleteGroups();
});
it("Should display all the subgroups", () => {
listingPage.goToItemDetails(groups[0]);
detailPage.checkListSubGroup([groups[1]]);
const added = "addedGroup";
createGroupModal.open().fillGroupForm(added).clickCreate();
detailPage.checkListSubGroup([added, groups[1]]);
});
it("Should display members", () => {
listingPage.goToItemDetails(groups[0]);
detailPage.clickMembersTab().checkListMembers(["user0", "user3"]);
detailPage
.clickIncludeSubGroups()
.checkListMembers(["user0", "user3", "user1", "user4", "user2"]);
});
it("Attributes CRUD test", () => {
listingPage.goToItemDetails(groups[0]);
detailPage
.clickAttributesTab()
.fillAttribute("key", "value")
.saveAttribute();
masthead.checkNotificationMessage("Group updated");
});
});
}); });

View file

@ -0,0 +1,59 @@
const expect = chai.expect;
export default class GroupDetailPage {
private groupNamesColumn = '[data-label="Group name"] > a';
private memberTab = "members";
private attributesTab = "attributes";
private memberNameColumn = 'tbody > tr > [data-label="Name"]';
private includeSubGroupsCheck = "includeSubGroupsCheck";
private keyInput = '[name="attributes[0].key"]';
private valueInput = '[name="attributes[0].value"]';
private saveAttributeBtn = ".pf-c-form__actions > .pf-m-primary";
checkListSubGroup(subGroups: string[]) {
cy.get(this.groupNamesColumn).should((groups) => {
expect(groups).to.have.length(subGroups.length);
for (let index = 0; index < subGroups.length; index++) {
const subGroup = subGroups[index];
expect(groups).to.contain(subGroup);
}
});
return this;
}
clickMembersTab() {
cy.getId(this.memberTab).click();
return this;
}
checkListMembers(members: string[]) {
cy.get(this.memberNameColumn).should((member) => {
expect(member).to.have.length(members.length);
for (let index = 0; index < members.length; index++) {
expect(member.eq(index)).to.contain(members[index]);
}
});
return this;
}
clickIncludeSubGroups() {
cy.getId(this.includeSubGroupsCheck).click();
return this;
}
clickAttributesTab() {
cy.getId(this.attributesTab).click();
return this;
}
fillAttribute(key: string, value: string) {
cy.get(this.keyInput).type(key).get(this.valueInput).type(value);
return this;
}
saveAttribute() {
cy.get(this.saveAttributeBtn).click();
return this;
}
}

View file

@ -30,4 +30,36 @@ export default class AdminClient {
)[0]; )[0];
await this.client.clients.del({ id: client.id! }); await this.client.clients.del({ id: client.id! });
} }
async createSubGroups(groups: string[]) {
await this.login();
let parentGroup = undefined;
const createdGroups = [];
for (const group of groups) {
if (!parentGroup) {
parentGroup = await this.client.groups.create({ name: group });
} else {
parentGroup = await this.client.groups.setOrCreateChild(
{ id: parentGroup.id },
{ name: group }
);
}
createdGroups.push(parentGroup);
}
return createdGroups;
}
async deleteGroups() {
await this.login();
const groups = await this.client.groups.find();
for (const group of groups) {
await this.client.groups.del({ id: group.id! });
}
}
async createUserInGroup(username: string, groupId: string) {
await this.login();
const user = await this.client.users.create({ username, enabled: true });
await this.client.users.addToGroup({ id: user.id!, groupId });
}
} }

View file

@ -19,7 +19,7 @@ import { AccessContextProvider, useAccess } from "./context/access/Access";
import { routes, RouteDef } from "./route-config"; import { routes, RouteDef } from "./route-config";
import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs"; import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs";
import { ForbiddenSection } from "./ForbiddenSection"; import { ForbiddenSection } from "./ForbiddenSection";
import { SubGroups } from "./groups/GroupsSection"; import { SubGroups } from "./groups/SubGroupsContext";
import { useRealm } from "./context/realm-context/RealmContext"; import { useRealm } from "./context/realm-context/RealmContext";
import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient"; import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient";
import { ErrorRenderer } from "./components/error/ErrorRenderer"; import { ErrorRenderer } from "./components/error/ErrorRenderer";

View file

@ -12,16 +12,19 @@ import {
} from "@patternfly/react-table"; } from "@patternfly/react-table";
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons"; import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
import { FormAccess } from "../components/form-access/FormAccess"; import { FormAccess } from "../form-access/FormAccess";
import { RoleFormType } from "./RealmRoleTabs";
import "./RealmRolesSection.css"; import "./attribute-form.css";
export type KeyValueType = { key: string; value: string }; export type KeyValueType = { key: string; value: string };
type RoleAttributesProps = { export type AttributeForm = {
form: UseFormMethods<RoleFormType>; attributes: KeyValueType[];
save: (role: RoleFormType) => void; };
export type AttributesFormProps = {
form: UseFormMethods<AttributeForm>;
save: (model: AttributeForm) => void;
reset: () => void; reset: () => void;
array: { array: {
fields: Partial<ArrayField<Record<string, any>, "id">>[]; fields: Partial<ArrayField<Record<string, any>, "id">>[];
@ -33,12 +36,32 @@ type RoleAttributesProps = {
}; };
}; };
export const RoleAttributes = ({ export const arrayToAttributes = (attributeArray: KeyValueType[]) => {
const initValue: { [index: string]: string[] } = {};
return attributeArray.reduce((acc, attribute) => {
acc[attribute.key] = [attribute.value];
return acc;
}, initValue);
};
export const attributesToArray = (attributes?: {
[key: string]: string[];
}): KeyValueType[] => {
if (!attributes || Object.keys(attributes).length == 0) {
return [];
}
return Object.keys(attributes).map((key) => ({
key: key,
value: attributes[key][0],
}));
};
export const AttributesForm = ({
form: { handleSubmit, register, formState, errors, watch }, form: { handleSubmit, register, formState, errors, watch },
save, save,
array: { fields, append, remove }, array: { fields, append, remove },
reset, reset,
}: RoleAttributesProps) => { }: AttributesFormProps) => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
const columns = ["Key", "Value"]; const columns = ["Key", "Value"];
@ -48,7 +71,7 @@ export const RoleAttributes = ({
<> <>
<FormAccess role="manage-realm" onSubmit={handleSubmit(save)}> <FormAccess role="manage-realm" onSubmit={handleSubmit(save)}>
<TableComposable <TableComposable
className="kc-role-attributes__table" className="kc-attributes__table"
aria-label="Role attribute keys and values" aria-label="Role attribute keys and values"
variant="compact" variant="compact"
borders={false} borders={false}
@ -93,7 +116,6 @@ export const RoleAttributes = ({
ref={register()} ref={register()}
aria-label="value-input" aria-label="value-input"
defaultValue={attribute.value} defaultValue={attribute.value}
validated={errors.description ? "error" : "default"}
/> />
</Td> </Td>
{rowIndex !== fields.length - 1 && fields.length - 1 !== 0 && ( {rowIndex !== fields.length - 1 && fields.length - 1 !== 0 && (
@ -106,7 +128,7 @@ export const RoleAttributes = ({
id={`minus-button-${rowIndex}`} id={`minus-button-${rowIndex}`}
aria-label={`remove ${attribute.key} with value ${attribute.value} `} aria-label={`remove ${attribute.key} with value ${attribute.value} `}
variant="link" variant="link"
className="kc-role-attributes__minus-icon" className="kc-attributes__minus-icon"
onClick={() => remove(rowIndex)} onClick={() => remove(rowIndex)}
> >
<MinusCircleIcon /> <MinusCircleIcon />
@ -120,7 +142,7 @@ export const RoleAttributes = ({
id={`minus-button-${rowIndex}`} id={`minus-button-${rowIndex}`}
aria-label={`remove ${attribute.key} with value ${attribute.value} `} aria-label={`remove ${attribute.key} with value ${attribute.value} `}
variant="link" variant="link"
className="kc-role-attributes__minus-icon" className="kc-attributes__minus-icon"
onClick={() => remove(rowIndex)} onClick={() => remove(rowIndex)}
> >
<MinusCircleIcon /> <MinusCircleIcon />
@ -130,7 +152,7 @@ export const RoleAttributes = ({
aria-label={t("roles:addAttributeText")} aria-label={t("roles:addAttributeText")}
id="plus-icon" id="plus-icon"
variant="link" variant="link"
className="kc-role-attributes__plus-icon" className="kc-attributes__plus-icon"
onClick={() => append({ key: "", value: "" })} onClick={() => append({ key: "", value: "" })}
icon={<PlusCircleIcon />} icon={<PlusCircleIcon />}
isDisabled={!formState.isValid} isDisabled={!formState.isValid}
@ -141,7 +163,7 @@ export const RoleAttributes = ({
))} ))}
</Tbody> </Tbody>
</TableComposable> </TableComposable>
<ActionGroup className="kc-role-attributes__action-group"> <ActionGroup className="kc-attributes__action-group">
<Button variant="primary" type="submit" isDisabled={!watchFirstKey}> <Button variant="primary" type="submit" isDisabled={!watchFirstKey}>
{t("common:save")} {t("common:save")}
</Button> </Button>

View file

@ -0,0 +1,23 @@
.kc-attributes__table {
/* even though the table is borderless, make the border under the th transparent */
--pf-c-table--border-width--base: 0;
--pf-c-table--m-compact--cell--first-last-child--PaddingLeft: 0;
}
.kc-attributes__plus-icon {
/* shift the button left to adjust for table cell padding */
margin-left: calc(var(--pf-global--spacer--md) * -1);
}
.pf-c-button.kc-attributes__minus-icon {
/* shift the button left to adjust for table cell padding */
margin-left: calc(var(--pf-global--spacer--md) * -1);
color: var(--pf-c-button--m-plain--Color);
}
.kc-attributes__action-group {
/* subtract the padding at the bottom of the table from the action group margin */
--pf-c-form__group--m-action--MarginTop: calc(
var(--pf-global--spacer--2xl) - var(--pf-global--spacer--sm)
);
}

View file

@ -3,7 +3,7 @@ import { Link, useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core"; import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
import { useSubGroups } from "../../groups/GroupsSection"; import { useSubGroups } from "../../groups/SubGroupsContext";
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
export const GroupBreadCrumbs = () => { export const GroupBreadCrumbs = () => {

View file

@ -3,7 +3,7 @@ import { mount } from "enzyme";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { GroupBreadCrumbs } from "../GroupBreadCrumbs"; import { GroupBreadCrumbs } from "../GroupBreadCrumbs";
import { SubGroups, useSubGroups } from "../../../groups/GroupsSection"; import { SubGroups, useSubGroups } from "../../../groups/SubGroupsContext";
const GroupCrumbs = () => { const GroupCrumbs = () => {
const { setSubGroups } = useSubGroups(); const { setSubGroups } = useSubGroups();

View file

@ -0,0 +1,70 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFieldArray, useForm } from "react-hook-form";
import { AlertVariant } from "@patternfly/react-core";
import { useAlerts } from "../components/alert/Alerts";
import {
arrayToAttributes,
AttributeForm,
AttributesForm,
attributesToArray,
} from "../components/attribute-form/AttributeForm";
import { useAdminClient } from "../context/auth/AdminClient";
import { getLastId } from "./groupIdUtils";
import { useSubGroups } from "./SubGroupsContext";
export const GroupAttributes = () => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const form = useForm<AttributeForm>({ mode: "onChange" });
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "attributes",
});
const id = getLastId(location.pathname);
const { currentGroup, subGroups, setSubGroups } = useSubGroups();
const convertAttributes = (attr?: Record<string, any>) => {
const attributes = attributesToArray(attr || currentGroup().attributes!);
attributes.push({ key: "", value: "" });
return attributes;
};
useEffect(() => {
form.setValue("attributes", convertAttributes());
}, [subGroups]);
const save = async (attributeForm: AttributeForm) => {
try {
const group = currentGroup();
const attributes = arrayToAttributes(attributeForm.attributes);
await adminClient.groups.update({ id: id! }, { ...group, attributes });
setSubGroups([
...subGroups.slice(0, subGroups.length - 1),
{ ...group, attributes },
]);
form.setValue("attributes", convertAttributes(attributes));
addAlert(t("groupUpdated"), AlertVariant.success);
} catch (error) {
addAlert(t("groupUpdateError", { error }), AlertVariant.danger);
}
};
return (
<AttributesForm
form={form}
save={save}
array={{ fields, append, remove }}
reset={() =>
form.reset({
attributes: convertAttributes(),
})
}
/>
);
};

206
src/groups/GroupTable.tsx Normal file
View file

@ -0,0 +1,206 @@
import React, { useEffect, useState } from "react";
import { Link, useHistory, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import _ from "lodash";
import {
AlertVariant,
Button,
Dropdown,
DropdownItem,
KebabToggle,
ToolbarItem,
} from "@patternfly/react-core";
import { UsersIcon } from "@patternfly/react-icons";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { useSubGroups } from "./SubGroupsContext";
import { useAlerts } from "../components/alert/Alerts";
import { useRealm } from "../context/realm-context/RealmContext";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { GroupsCreateModal } from "./GroupsCreateModal";
import { getLastId } from "./groupIdUtils";
type GroupTableData = GroupRepresentation & {
membersLength?: number;
};
export const GroupTable = () => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { realm } = useRealm();
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const { subGroups } = useSubGroups();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const history = useHistory();
const location = useLocation();
const id = getLastId(location.pathname);
useEffect(() => {
refresh();
}, [subGroups]);
const getMembers = async (id: string) => {
const response = await adminClient.groups.listMembers({ id });
return response ? response.length : 0;
};
const loader = async () => {
const groupsData = id
? (await adminClient.groups.findOne({ id })).subGroups
: await adminClient.groups.find();
if (groupsData) {
const memberPromises = groupsData.map((group) => getMembers(group.id!));
const memberData = await Promise.all(memberPromises);
return _.cloneDeep(groupsData).map((group: GroupTableData, i) => {
group.membersLength = memberData[i];
return group;
});
} else {
history.push(`/${realm}/groups`);
}
return [];
};
const deleteGroup = async (group: GroupRepresentation) => {
try {
await adminClient.groups.del({
id: group.id!,
});
addAlert(t("groupDelete"), AlertVariant.success);
} catch (error) {
addAlert(t("groupDeleteError", { error }), AlertVariant.danger);
}
return true;
};
const multiDelete = async () => {
if (selectedRows!.length !== 0) {
const chainedPromises = selectedRows!.map((group) => deleteGroup(group));
await Promise.all(chainedPromises);
addAlert(t("groupsDeleted"), AlertVariant.success);
setSelectedRows([]);
refresh();
}
};
const GroupNameCell = (group: GroupTableData) => (
<>
<Link key={group.id} to={`${location.pathname}/${group.id}`}>
{group.name}
</Link>
</>
);
const GroupMemberCell = (group: GroupTableData) => (
<div className="keycloak-admin--groups__member-count">
<UsersIcon key={`user-icon-${group.id}`} />
{group.membersLength}
</div>
);
const handleModalToggle = () => {
setIsCreateModalOpen(!isCreateModalOpen);
};
return (
<>
<KeycloakDataTable
key={key}
onSelect={(rows) => setSelectedRows([...rows])}
canSelectAll={false}
loader={loader}
ariaLabelKey="groups:groups"
searchPlaceholderKey="groups:searchForGroups"
toolbarItem={
<>
<ToolbarItem>
<Button
data-testid="openCreateGroupModal"
variant="primary"
onClick={handleModalToggle}
>
{t("createGroup")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Dropdown
toggle={
<KebabToggle onToggle={() => setIsKebabOpen(!isKebabOpen)} />
}
isOpen={isKebabOpen}
isPlain
dropdownItems={[
<DropdownItem
key="action"
component="button"
onClick={() => {
multiDelete();
setIsKebabOpen(false);
}}
>
{t("common:delete")}
</DropdownItem>,
]}
/>
</ToolbarItem>
</>
}
actions={[
{
title: t("moveTo"),
onRowClick: () => console.log("TO DO: Add move to functionality"),
},
{
title: t("common:delete"),
onRowClick: async (group: GroupRepresentation) => {
return deleteGroup(group);
},
},
]}
columns={[
{
name: "name",
displayKey: "groups:groupName",
cellRenderer: GroupNameCell,
},
{
name: "members",
displayKey: "groups:members",
cellRenderer: GroupMemberCell,
},
]}
emptyState={
<ListEmptyState
hasIcon={true}
message={t(`noGroupsInThis${id ? "SubGroup" : "Realm"}`)}
instructions={t(
`noGroupsInThis${id ? "SubGroup" : "Realm"}Instructions`
)}
primaryActionText={t("createGroup")}
onPrimaryAction={handleModalToggle}
/>
}
/>
{isCreateModalOpen && (
<GroupsCreateModal
id={id}
handleModalToggle={handleModalToggle}
refresh={refresh}
/>
)}
</>
);
};

View file

@ -1,153 +1,44 @@
import React, { import React, { useEffect, useState } from "react";
createContext, import { useHistory } from "react-router-dom";
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { Link, useHistory, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useErrorHandler } from "react-error-boundary";
import { import {
Button,
Dropdown,
DropdownItem, DropdownItem,
KebabToggle,
PageSection, PageSection,
PageSectionVariants, PageSectionVariants,
ToolbarItem,
AlertVariant, AlertVariant,
Tab,
TabTitleText,
Tabs,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { UsersIcon } from "@patternfly/react-icons";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation"; import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { GroupsCreateModal } from "./GroupsCreateModal";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import "./GroupsSection.css";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
type GroupTableData = GroupRepresentation & { import { useSubGroups } from "./SubGroupsContext";
membersLength?: number; import { GroupTable } from "./GroupTable";
}; import { getId, getLastId } from "./groupIdUtils";
import { Members } from "./Members";
import { GroupAttributes } from "./GroupAttributes";
type SubGroupsProps = { import "./GroupsSection.css";
subGroups: GroupRepresentation[];
setSubGroups: (group: GroupRepresentation[]) => void;
clear: () => void;
remove: (group: GroupRepresentation) => void;
};
const SubGroupContext = createContext<SubGroupsProps>({
subGroups: [],
setSubGroups: () => {},
clear: () => {},
remove: () => {},
});
export const SubGroups = ({ children }: { children: ReactNode }) => {
const [subGroups, setSubGroups] = useState<GroupRepresentation[]>([]);
const clear = () => setSubGroups([]);
const remove = (group: GroupRepresentation) =>
setSubGroups(
subGroups.slice(
0,
subGroups.findIndex((g) => g.id === group.id)
)
);
return (
<SubGroupContext.Provider
value={{ subGroups, setSubGroups, clear, remove }}
>
{children}
</SubGroupContext.Provider>
);
};
export const useSubGroups = () => useContext(SubGroupContext);
const getId = (pathname: string) => {
const pathParts = pathname.substr(1).split("/");
return pathParts.length > 1 ? pathParts.splice(2) : undefined;
};
const getLastId = (pathname: string) => {
const pathParts = getId(pathname);
return pathParts ? pathParts[pathParts.length - 1] : undefined;
};
export const GroupsSection = () => { export const GroupsSection = () => {
const { t } = useTranslation("groups"); const { t } = useTranslation("groups");
const [activeTab, setActiveTab] = useState(0);
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const { subGroups, setSubGroups } = useSubGroups(); const { subGroups, setSubGroups } = useSubGroups();
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const { realm } = useRealm(); const { realm } = useRealm();
const errorHandler = useErrorHandler();
const history = useHistory(); const history = useHistory();
const location = useLocation();
const id = getLastId(location.pathname); const id = getLastId(location.pathname);
const [key, setKey] = useState("");
const refresh = () => setKey(`${new Date().getTime()}`);
const getMembers = async (id: string) => {
const response = await adminClient.groups.listMembers({ id });
return response ? response.length : 0;
};
const loader = async () => {
let groupsData;
if (!id) {
groupsData = await adminClient.groups.find();
} else {
const ids = getId(location.pathname);
const isNavigationStateInValid = ids && ids.length !== subGroups.length;
if (isNavigationStateInValid) {
const groups = [];
for (const i of ids!) {
const group = await adminClient.groups.findOne({ id: i });
if (group) groups.push(group);
}
setSubGroups(groups);
groupsData = groups.pop()?.subGroups!;
} else {
const group = await adminClient.groups.findOne({ id });
if (group) {
setSubGroups([...subGroups, group]);
groupsData = group.subGroups!;
}
}
}
if (groupsData) {
const memberPromises = groupsData.map((group) => getMembers(group.id!));
const memberData = await Promise.all(memberPromises);
return groupsData.map((group: GroupTableData, i) => {
group.membersLength = memberData[i];
return group;
});
} else {
history.push(`/${realm}/groups`);
}
return [];
};
useEffect(() => {
refresh();
}, [id]);
const handleModalToggle = () => {
setIsCreateModalOpen(!isCreateModalOpen);
};
const deleteGroup = async (group: GroupRepresentation) => { const deleteGroup = async (group: GroupRepresentation) => {
try { try {
await adminClient.groups.del({ await adminClient.groups.del({
@ -160,30 +51,37 @@ export const GroupsSection = () => {
return true; return true;
}; };
const multiDelete = async () => { useEffect(
if (selectedRows!.length !== 0) { () =>
const chainedPromises = selectedRows!.map((group) => deleteGroup(group)); asyncStateFetch(
async () => {
await Promise.all(chainedPromises); const ids = getId(location.pathname);
addAlert(t("groupsDeleted"), AlertVariant.success); const isNavigationStateInValid =
setSelectedRows([]); ids && ids.length !== subGroups.length + 1;
refresh(); if (isNavigationStateInValid) {
} const groups: GroupRepresentation[] = [];
}; for (const i of ids!) {
const group = await adminClient.groups.findOne({ id: i });
const GroupNameCell = (group: GroupTableData) => ( if (group) groups.push(group);
<> }
<Link key={group.id} to={`${location.pathname}/${group.id}`}> return groups;
{group.name} } else {
</Link> if (id) {
</> const group = await adminClient.groups.findOne({ id: id });
); if (group) {
return [...subGroups, group];
const GroupMemberCell = (group: GroupTableData) => ( } else {
<div className="keycloak-admin--groups__member-count"> return subGroups;
<UsersIcon key={`user-icon-${group.id}`} /> }
{group.membersLength} } else {
</div> return subGroups;
}
}
},
(groups: GroupRepresentation[]) => setSubGroups(groups),
errorHandler
),
[id]
); );
return ( return (
@ -216,93 +114,37 @@ export const GroupsSection = () => {
]} ]}
/> />
<PageSection variant={PageSectionVariants.light}> <PageSection variant={PageSectionVariants.light}>
<KeycloakDataTable {subGroups.length > 0 && (
key={key} <Tabs
onSelect={(rows) => setSelectedRows([...rows])} activeKey={activeTab}
canSelectAll={false} isSecondary
loader={loader} onSelect={(_, key) => setActiveTab(key as number)}
ariaLabelKey="groups:groups" isBox
searchPlaceholderKey="groups:searchForGroups" >
toolbarItem={ <Tab
<> data-testid="groups"
<ToolbarItem> eventKey={0}
<Button title={<TabTitleText>{t("childGroups")}</TabTitleText>}
data-testid="openCreateGroupModal" >
variant="primary" <GroupTable />
onClick={handleModalToggle} </Tab>
> <Tab
{t("createGroup")} data-testid="members"
</Button> eventKey={1}
</ToolbarItem> title={<TabTitleText>{t("members")}</TabTitleText>}
<ToolbarItem> >
<Dropdown <Members />
toggle={ </Tab>
<KebabToggle <Tab
onToggle={() => setIsKebabOpen(!isKebabOpen)} data-testid="attributes"
/> eventKey={2}
} title={<TabTitleText>{t("attributes")}</TabTitleText>}
isOpen={isKebabOpen} >
isPlain <GroupAttributes />
dropdownItems={[ </Tab>
<DropdownItem </Tabs>
key="action"
component="button"
onClick={() => {
multiDelete();
setIsKebabOpen(false);
}}
>
{t("common:delete")}
</DropdownItem>,
]}
/>
</ToolbarItem>
</>
}
actions={[
{
title: t("moveTo"),
onRowClick: () => console.log("TO DO: Add move to functionality"),
},
{
title: t("common:delete"),
onRowClick: async (group: GroupRepresentation) => {
return deleteGroup(group);
},
},
]}
columns={[
{
name: "name",
displayKey: "groups:groupName",
cellRenderer: GroupNameCell,
},
{
name: "members",
displayKey: "groups:members",
cellRenderer: GroupMemberCell,
},
]}
emptyState={
<ListEmptyState
hasIcon={true}
message={t(`noGroupsInThis${id ? "SubGroup" : "Realm"}`)}
instructions={t(
`noGroupsInThis${id ? "SubGroup" : "Realm"}Instructions`
)}
primaryActionText={t("createGroup")}
onPrimaryAction={() => handleModalToggle()}
/>
}
/>
{isCreateModalOpen && (
<GroupsCreateModal
id={id}
handleModalToggle={handleModalToggle}
refresh={refresh}
/>
)} )}
{subGroups.length === 0 && <GroupTable />}
</PageSection> </PageSection>
</> </>
); );

133
src/groups/Members.tsx Normal file
View file

@ -0,0 +1,133 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import _ from "lodash";
import { Button, Checkbox, ToolbarItem } from "@patternfly/react-core";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAdminClient } from "../context/auth/AdminClient";
import { emptyFormatter } from "../util";
import { getLastId } from "./groupIdUtils";
import { useSubGroups } from "./SubGroupsContext";
type MembersOf = UserRepresentation & {
membership: GroupRepresentation[];
};
export const Members = () => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const id = getLastId(location.pathname);
const [includeSubGroup, setIncludeSubGroup] = useState(false);
const { currentGroup, subGroups } = useSubGroups();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
useEffect(() => {
refresh();
}, [id, subGroups, includeSubGroup]);
const getMembership = async (id: string) =>
await adminClient.users.listGroups({ id: id! });
const getSubGroups = (groups: GroupRepresentation[]) => {
let subGroups: GroupRepresentation[] = [];
for (const group of groups!) {
subGroups.push(group);
const subs = getSubGroups(group.subGroups!);
subGroups = subGroups.concat(subs);
}
return subGroups;
};
const loader = async (first?: number, max?: number) => {
let members = await adminClient.groups.listMembers({
id: id!,
first,
max,
});
if (includeSubGroup) {
const subGroups = getSubGroups(currentGroup().subGroups!);
for (const group of subGroups) {
members = members.concat(
await adminClient.groups.listMembers({ id: group.id! })
);
}
members = _.uniqBy(members, (member) => member.username);
}
const memberOfPromises = await Promise.all(
members.map((member) => getMembership(member.id!))
);
return members.map((member: UserRepresentation, i) => {
return { ...member, membership: memberOfPromises[i] };
});
};
const MemberOfRenderer = (member: MembersOf) => {
return (
<>
{member.membership.map((group) => (
<>{group.path} </>
))}
</>
);
};
return (
<KeycloakDataTable
key={key}
loader={loader}
ariaLabelKey="groups:members"
isPaginated
toolbarItem={
<>
<ToolbarItem>
<Button data-testid="addMember" variant="primary">
{t("addMember")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Checkbox
data-testid="includeSubGroupsCheck"
label={t("includeSubGroups")}
id="kc-include-sub-groups"
isChecked={includeSubGroup}
onChange={() => setIncludeSubGroup(!includeSubGroup)}
/>
</ToolbarItem>
</>
}
columns={[
{
name: "username",
displayKey: "common:name",
},
{
name: "email",
displayKey: "groups:email",
cellFormatters: [emptyFormatter()],
},
{
name: "firstName",
displayKey: "groups:firstName",
cellFormatters: [emptyFormatter()],
},
{
name: "lastName",
displayKey: "groups:lastName",
cellFormatters: [emptyFormatter()],
},
{
name: "membership",
displayKey: "groups:membership",
cellRenderer: MemberOfRenderer,
},
]}
/>
);
};

View file

@ -0,0 +1,43 @@
import React, { createContext, ReactNode, useContext, useState } from "react";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
type SubGroupsProps = {
subGroups: GroupRepresentation[];
setSubGroups: (group: GroupRepresentation[]) => void;
clear: () => void;
remove: (group: GroupRepresentation) => void;
currentGroup: () => GroupRepresentation;
};
const SubGroupContext = createContext<SubGroupsProps>({
subGroups: [],
setSubGroups: () => {},
clear: () => {},
remove: () => {},
currentGroup: () => {
return {};
},
});
export const SubGroups = ({ children }: { children: ReactNode }) => {
const [subGroups, setSubGroups] = useState<GroupRepresentation[]>([]);
const clear = () => setSubGroups([]);
const remove = (group: GroupRepresentation) =>
setSubGroups(
subGroups.slice(
0,
subGroups.findIndex((g) => g.id === group.id)
)
);
const currentGroup = () => subGroups[subGroups.length - 1];
return (
<SubGroupContext.Provider
value={{ subGroups, setSubGroups, clear, remove, currentGroup }}
>
{children}
</SubGroupContext.Provider>
);
};
export const useSubGroups = () => useContext(SubGroupContext);

View file

@ -0,0 +1,9 @@
export const getId = (pathname: string) => {
const pathParts = pathname.substr(1).split("/");
return pathParts.length > 1 ? pathParts.splice(2) : undefined;
};
export const getLastId = (pathname: string) => {
const pathParts = getId(pathname);
return pathParts ? pathParts[pathParts.length - 1] : undefined;
};

View file

@ -1,6 +1,7 @@
{ {
"groups": { "groups": {
"groups": "Groups", "groups": "Groups",
"childGroups": "Child groups",
"createGroup": "Create group", "createGroup": "Create group",
"groupName": "Group name", "groupName": "Group name",
"searchForGroups": "Search for groups", "searchForGroups": "Search for groups",
@ -10,6 +11,9 @@
"deleteGroup": "Delete group", "deleteGroup": "Delete group",
"search": "Search", "search": "Search",
"members": "Members", "members": "Members",
"searchMembers": "Search members",
"addMember": "Add member",
"includeSubGroups": "Include sub-group users",
"path": "Path", "path": "Path",
"moveTo": "Move to", "moveTo": "Move to",
"tableOfGroups": "Table of groups", "tableOfGroups": "Table of groups",
@ -18,6 +22,10 @@
"couldNotCreateGroup": "Could not create group {{error}}", "couldNotCreateGroup": "Could not create group {{error}}",
"createAGroup": "Create a group", "createAGroup": "Create a group",
"create": "Create", "create": "Create",
"email": "Email",
"lastName": "Last name",
"firstName": "First name",
"membership": "Membership",
"noSearchResults": "No search results", "noSearchResults": "No search results",
"noSearchResultsInstructions": "Click on the search bar above to search for groups", "noSearchResultsInstructions": "Click on the search bar above to search for groups",
"noGroupsInThisRealm": "No groups in this realm", "noGroupsInThisRealm": "No groups in this realm",
@ -26,6 +34,9 @@
"noGroupsInThisSubGroupInstructions": "You haven't created any groups in this sub group.", "noGroupsInThisSubGroupInstructions": "You haven't created any groups in this sub group.",
"groupDelete": "Group deleted", "groupDelete": "Group deleted",
"groupsDeleted": "Groups deleted", "groupsDeleted": "Groups deleted",
"groupDeleteError": "Error deleting group {error}" "groupDeleteError": "Error deleting group {error}",
"attributes": "Attributes",
"groupUpdated": "Group updated",
"groupUpdateError": "Error updating group {error}"
} }
} }

View file

@ -9,13 +9,14 @@ import {
ModalVariant, ModalVariant,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form"; import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
import { useAdminClient } from "../context/auth/AdminClient";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { CaretDownIcon, FilterIcon } from "@patternfly/react-icons"; import { CaretDownIcon, FilterIcon } from "@patternfly/react-icons";
import { AliasRendererComponent } from "./AliasRendererComponent"; import { AliasRendererComponent } from "./AliasRendererComponent";
import _ from "lodash";
import { useErrorHandler } from "react-error-boundary";
export type AssociatedRolesModalProps = { export type AssociatedRolesModalProps = {
open: boolean; open: boolean;
@ -24,27 +25,12 @@ export type AssociatedRolesModalProps = {
existingCompositeRoles: RoleRepresentation[]; existingCompositeRoles: RoleRepresentation[];
}; };
const attributesToArray = (attributes: { [key: string]: string }): any => {
if (!attributes || Object.keys(attributes).length === 0) {
return [
{
key: "",
value: "",
},
];
}
return Object.keys(attributes).map((key) => ({
key: key,
value: attributes[key],
}));
};
export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => { export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
const form = useForm<RoleRepresentation>({ mode: "onChange" });
const [name, setName] = useState(""); const [name, setName] = useState("");
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const [selectedRows, setSelectedRows] = useState<RoleRepresentation[]>([]); const [selectedRows, setSelectedRows] = useState<RoleRepresentation[]>([]);
const errorHandler = useErrorHandler();
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
const [filterType, setFilterType] = useState("roles"); const [filterType, setFilterType] = useState("roles");
@ -54,18 +40,7 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const alphabetize = (rolesList: RoleRepresentation[]) => { const alphabetize = (rolesList: RoleRepresentation[]) => {
return rolesList.sort((r1, r2) => { return _.sortBy(rolesList, (role) => role.name?.toUpperCase());
const r1Name = r1.name?.toUpperCase();
const r2Name = r2.name?.toUpperCase();
if (r1Name! < r2Name!) {
return -1;
}
if (r1Name! > r2Name!) {
return 1;
}
return 0;
});
}; };
const loader = async () => { const loader = async () => {
@ -127,26 +102,17 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
}, [filterType]); }, [filterType]);
useEffect(() => { useEffect(() => {
(async () => { if (id) {
if (id) { return asyncStateFetch(
const fetchedRole = await adminClient.roles.findOneById({ id }); () => adminClient.roles.findOneById({ id }),
setName(fetchedRole.name!); (fetchedRole) => setName(fetchedRole.name!),
setupForm(fetchedRole); errorHandler
} else { );
setName(t("createRole")); } else {
} setName(t("createRole"));
})(); }
}, []); }, []);
const setupForm = (role: RoleRepresentation) =>
Object.entries(role).map((entry) => {
if (entry[0] === "attributes") {
form.setValue(entry[0], attributesToArray(entry[1]));
} else {
form.setValue(entry[0], entry[1]);
}
});
const onFilterDropdownToggle = () => { const onFilterDropdownToggle = () => {
setIsFilterDropdownOpen(!isFilterDropdownOpen); setIsFilterDropdownOpen(!isFilterDropdownOpen);
}; };
@ -224,7 +190,6 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
/> />
} }
canSelectAll canSelectAll
isPaginated
onSelect={(rows) => { onSelect={(rows) => {
setSelectedRows([...rows]); setSelectedRows([...rows]);
}} }}
@ -245,7 +210,6 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
message={t("noRolesInThisRealm")} message={t("noRolesInThisRealm")}
instructions={t("noRolesInThisRealmInstructions")} instructions={t("noRolesInThisRealmInstructions")}
primaryActionText={t("createRole")} primaryActionText={t("createRole")}
// onPrimaryAction={goToCreate}
/> />
} }
/> />

View file

@ -7,6 +7,7 @@ import {
ButtonVariant, ButtonVariant,
Checkbox, Checkbox,
PageSection, PageSection,
ToolbarItem,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
@ -193,31 +194,36 @@ export const AssociatedRolesTab = ({
}} }}
toolbarItem={ toolbarItem={
<> <>
<Checkbox <ToolbarItem>
label="Hide inherited roles" <Checkbox
key="associated-roles-check" label="Hide inherited roles"
id="kc-hide-inherited-roles-checkbox" key="associated-roles-check"
onChange={() => setIsInheritedHidden(!isInheritedHidden)} id="kc-hide-inherited-roles-checkbox"
isChecked={isInheritedHidden} onChange={() => setIsInheritedHidden(!isInheritedHidden)}
/> isChecked={isInheritedHidden}
<Button />
className="kc-add-role-button" </ToolbarItem>
key="add-role-button" <ToolbarItem>
onClick={() => toggleModal()} <Button
data-testid="add-role-button" key="add-role-button"
> onClick={() => toggleModal()}
{t("addRole")} data-testid="add-role-button"
</Button> >
<Button {t("addRole")}
variant="link" </Button>
isDisabled={selectedRows.length === 0} </ToolbarItem>
key="remove-role-button" <ToolbarItem>
onClick={() => { <Button
toggleDeleteAssociatedRolesDialog(); variant="link"
}} isDisabled={selectedRows.length === 0}
> key="remove-role-button"
{t("removeRoles")} onClick={() => {
</Button> toggleDeleteAssociatedRolesDialog();
}}
>
{t("removeRoles")}
</Button>
</ToolbarItem>
</> </>
} }
actions={[ actions={[

View file

@ -15,7 +15,12 @@ import { useAlerts } from "../components/alert/Alerts";
import { useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import Composites from "keycloak-admin/lib/defs/roleRepresentation"; import Composites from "keycloak-admin/lib/defs/roleRepresentation";
import { KeyValueType, RoleAttributes } from "./RoleAttributes"; import {
KeyValueType,
AttributesForm,
attributesToArray,
arrayToAttributes,
} from "../components/attribute-form/AttributeForm";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { RealmRoleForm } from "./RealmRoleForm"; import { RealmRoleForm } from "./RealmRoleForm";
@ -25,26 +30,6 @@ import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { AssociatedRolesTab } from "./AssociatedRolesTab"; import { AssociatedRolesTab } from "./AssociatedRolesTab";
import { UsersInRoleTab } from "./UsersInRoleTab"; import { UsersInRoleTab } from "./UsersInRoleTab";
const arrayToAttributes = (attributeArray: KeyValueType[]) => {
const initValue: { [index: string]: string[] } = {};
return attributeArray.reduce((acc, attribute) => {
acc[attribute.key] = [attribute.value];
return acc;
}, initValue);
};
const attributesToArray = (attributes?: {
[key: string]: string[];
}): KeyValueType[] => {
if (!attributes || Object.keys(attributes).length === 0) {
return [];
}
return Object.keys(attributes).map((key) => ({
key: key,
value: attributes[key][0],
}));
};
export type RoleFormType = Omit<RoleRepresentation, "attributes"> & { export type RoleFormType = Omit<RoleRepresentation, "attributes"> & {
attributes: KeyValueType[]; attributes: KeyValueType[];
}; };
@ -329,7 +314,7 @@ export const RealmRoleTabs = () => {
eventKey="attributes" eventKey="attributes"
title={<TabTitleText>{t("attributes")}</TabTitleText>} title={<TabTitleText>{t("attributes")}</TabTitleText>}
> >
<RoleAttributes <AttributesForm
form={form} form={form}
save={save} save={save}
array={{ fields, append, remove }} array={{ fields, append, remove }}

View file

@ -1,30 +1,4 @@
.kc-role-attributes__table {
/* even though the table is borderless, make the border under the th transparent */
--pf-c-table--border-width--base: 0;
--pf-c-table--m-compact--cell--first-last-child--PaddingLeft: 0;
}
.kc-role-attributes__plus-icon {
/* shift the button left to adjust for table cell padding */
margin-left: calc(var(--pf-global--spacer--md) * -1);
}
.pf-c-button.kc-role-attributes__minus-icon {
/* shift the button left to adjust for table cell padding */
margin-left: calc(var(--pf-global--spacer--md) * -1);
color: var(--pf-c-button--m-plain--Color);
}
.kc-add-role-button {
margin-left: var(--pf-global--spacer--lg);
}
.kc-role-attributes__action-group {
/* subtract the padding at the bottom of the table from the action group margin */
--pf-c-form__group--m-action--MarginTop: calc(
var(--pf-global--spacer--2xl) - var(--pf-global--spacer--sm)
);
}
.kc-who-will-appear-button { .kc-who-will-appear-button {
padding-left: 0px; padding-left: 0px;