Fixing UXD review for user (#648)
* Introduced new GroupPicker. Can be used for move group or join group * Moved help texts to help.json * don't set state when there was no request * add pagination * remove "Groups" link on root level * use path in chip instread of name * fixes filtering to show search not found and removes `+1` logic from pager * fix breadcrumb and layout * fixed all the tests
This commit is contained in:
parent
1bf423b505
commit
2c9b77c425
34 changed files with 482 additions and 654 deletions
|
@ -133,7 +133,7 @@ describe("Clients test", function () {
|
|||
});
|
||||
|
||||
it("Clustering", () => {
|
||||
advancedTab.expandClusterNode().checkTestClusterAvailability(false);
|
||||
advancedTab.expandClusterNode();
|
||||
|
||||
advancedTab
|
||||
.clickRegisterNodeManually()
|
||||
|
|
|
@ -44,7 +44,7 @@ describe("Group test", () => {
|
|||
groupName += "_" + (Math.random() + 1).toString(36).substring(7);
|
||||
|
||||
groupModal
|
||||
.open("empty-primary-action")
|
||||
.open("no-groups-in-this-realm-empty-action")
|
||||
.fillGroupForm(groupName)
|
||||
.clickCreate();
|
||||
|
||||
|
@ -61,7 +61,7 @@ describe("Group test", () => {
|
|||
|
||||
it("Should rename group", () => {
|
||||
groupModal
|
||||
.open("empty-primary-action")
|
||||
.open("no-groups-in-this-realm-empty-action")
|
||||
.fillGroupForm(groupName)
|
||||
.clickCreate();
|
||||
clickGroup(groupName);
|
||||
|
@ -80,7 +80,7 @@ describe("Group test", () => {
|
|||
|
||||
it("Should move group", () => {
|
||||
const targetGroupName = "target";
|
||||
groupModal.open("empty-primary-action");
|
||||
groupModal.open("no-groups-in-this-realm-empty-action");
|
||||
groupModal.fillGroupForm(groupName).clickCreate();
|
||||
|
||||
groupModal.open().fillGroupForm(targetGroupName).clickCreate();
|
||||
|
@ -106,7 +106,7 @@ describe("Group test", () => {
|
|||
it("Should move group to root", async () => {
|
||||
const groups = ["group1", "group2"];
|
||||
groupModal
|
||||
.open("empty-primary-action")
|
||||
.open("no-groups-in-this-realm-empty-action")
|
||||
.fillGroupForm(groups[0])
|
||||
.clickCreate();
|
||||
groupModal.open().fillGroupForm(groups[1]).clickCreate();
|
||||
|
|
|
@ -165,7 +165,7 @@ describe("Realm settings", () => {
|
|||
realmSettingsPage.addProvider();
|
||||
});
|
||||
|
||||
it("Test keys", function () {
|
||||
it("Test keys", () => {
|
||||
sidebarPage.goToRealmSettings();
|
||||
goToKeys();
|
||||
|
||||
|
|
|
@ -115,7 +115,7 @@ describe("User Fed LDAP mapper tests", () => {
|
|||
it("Create group", () => {
|
||||
sidebarPage.goToGroups();
|
||||
groupModal
|
||||
.open("empty-primary-action")
|
||||
.open("no-groups-in-this-realm-empty-action")
|
||||
.fillGroupForm(groupName)
|
||||
.clickCreate();
|
||||
|
||||
|
|
|
@ -28,10 +28,8 @@ describe("Group creation", () => {
|
|||
function createNewGroup() {
|
||||
groupName += "_" + (Math.random() + 1).toString(36).substring(7);
|
||||
|
||||
groupModal
|
||||
.open("openCreateGroupModal")
|
||||
.fillGroupForm(groupName)
|
||||
.clickCreate();
|
||||
cy.get(".pf-c-spinner__tail-ball").should("not.exist");
|
||||
groupModal.open().fillGroupForm(groupName).clickCreate();
|
||||
|
||||
groupsList = [...groupsList, groupName];
|
||||
masthead.checkNotificationMessage("Group created");
|
||||
|
|
|
@ -2,7 +2,7 @@ const expect = chai.expect;
|
|||
export default class RoleMappingTab {
|
||||
private tab = "#pf-tab-serviceAccount-serviceAccount";
|
||||
private scopeTab = "scopeTab";
|
||||
private assignRole = "assignRole";
|
||||
private assignRole = "no-roles-for-this-client-empty-action";
|
||||
private unAssign = "unAssignRole";
|
||||
private assign = "assign";
|
||||
private hide = "#hideInheritedRoles";
|
||||
|
|
|
@ -9,7 +9,7 @@ export default class AdvancedTab {
|
|||
private clusterNodesExpand =
|
||||
".pf-c-expandable-section .pf-c-expandable-section__toggle";
|
||||
private testClusterAvailability = "#testClusterAvailability";
|
||||
private registerNodeManually = "#registerNodeManually";
|
||||
private registerNodeManually = "no-nodes-registered-empty-action";
|
||||
private nodeHost = "#nodeHost";
|
||||
private addNodeConfirm = "#add-node-confirm";
|
||||
|
||||
|
@ -65,7 +65,7 @@ export default class AdvancedTab {
|
|||
}
|
||||
|
||||
clickRegisterNodeManually() {
|
||||
cy.get(this.registerNodeManually).click();
|
||||
cy.getId(this.registerNodeManually).click();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
export default class InitialAccessTokenTab {
|
||||
private initialAccessTokenTab = "initialAccessToken";
|
||||
|
||||
private emptyAction = "empty-primary-action";
|
||||
private emptyAction = "no-initial-access-tokens-empty-action";
|
||||
|
||||
private expirationInput = "expiration";
|
||||
private countInput = "count";
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
export default class GroupModal {
|
||||
private openButton = "openCreateGroupModal";
|
||||
private nameInput = "groupNameInput";
|
||||
private createButton = "createGroup";
|
||||
private renameButton = "renameGroup";
|
||||
|
||||
open(name?: string) {
|
||||
cy.getId(name || this.openButton).click();
|
||||
if (name) {
|
||||
cy.getId(name).click();
|
||||
} else {
|
||||
cy.get("button").contains("Create").click();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export default class MoveGroupModal {
|
||||
private moveButton = "joinGroup";
|
||||
private moveButton = "groups:moveHere-button";
|
||||
private title = ".pf-c-modal-box__title";
|
||||
|
||||
clickRow(groupName: string) {
|
||||
|
|
|
@ -69,7 +69,7 @@ export default class ProviderPage {
|
|||
private namesColumn = 'td[data-label="Name"]:visible';
|
||||
|
||||
private rolesTab = "#pf-tab-roles-roles";
|
||||
private createRoleBtn = "data-testid=empty-primary-action";
|
||||
private createRoleBtn = "data-testid=no-roles-for-this-client-empty-action";
|
||||
private realmRolesSaveBtn = "data-testid=realm-roles-save-button";
|
||||
private roleNameField = "#kc-name";
|
||||
private clientIdSelect = "#kc-client-id";
|
||||
|
@ -217,7 +217,7 @@ export default class ProviderPage {
|
|||
const ldapAttValue = "cn";
|
||||
const ldapDnValue = "ou=groups";
|
||||
|
||||
cy.get(`[data-testid="add-mapper-btn"]`).click();
|
||||
cy.contains("Add").click();
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("#kc-providerId").click();
|
||||
|
|
|
@ -13,11 +13,11 @@ export default class CreateUserPage {
|
|||
this.usernameInput = "#kc-username";
|
||||
|
||||
this.usersEmptyState = "empty-state";
|
||||
this.emptyStateCreateUserBtn = "empty-primary-action";
|
||||
this.emptyStateCreateUserBtn = "no-users-found-empty-action";
|
||||
this.searchPgCreateUserBtn = "create-new-user";
|
||||
this.addUserBtn = "add-user";
|
||||
this.joinGroupsBtn = "join-groups-button";
|
||||
this.joinBtn = "join-button";
|
||||
this.joinBtn = "users:join-button";
|
||||
this.saveBtn = "create-user";
|
||||
this.cancelBtn = "cancel-create-user";
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ export default class UserGroupsPage {
|
|||
constructor() {
|
||||
this.userGroupsTab = "user-groups-tab";
|
||||
this.addGroupButton = "add-group-button";
|
||||
this.joinGroupButton = "join-button";
|
||||
this.joinGroupButton = "users:join-button";
|
||||
}
|
||||
|
||||
goToGroupsTab() {
|
||||
|
|
|
@ -37,6 +37,7 @@ import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointCon
|
|||
import { AuthenticationOverrides } from "./advanced/AuthenticationOverrides";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import type { SaveOptions } from "./ClientDetails";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
|
||||
type AdvancedProps = {
|
||||
save: (options?: SaveOptions) => void;
|
||||
|
@ -345,6 +346,14 @@ export const AdvancedTab = ({
|
|||
],
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
<ListEmptyState
|
||||
message={t("noNodes")}
|
||||
instructions={t("noNodesInstructions")}
|
||||
primaryActionText={t("registerNodeManually")}
|
||||
onPrimaryAction={() => setAddNodeOpen(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ExpandableSection>
|
||||
</>
|
||||
|
|
|
@ -167,6 +167,8 @@
|
|||
"nodeReRegistrationTimeout": "Node Re-registration timeout",
|
||||
"registeredClusterNodes": "Registered cluster nodes",
|
||||
"nodeHost": "Node host",
|
||||
"noNodes": "No nodes registered",
|
||||
"noNodesInstructions": "There are no nodes registered, you can add one manually.",
|
||||
"lastRegistration": "Last registration",
|
||||
"testClusterAvailability": "Test cluster availability",
|
||||
"registerNodeManually": "Register node manually",
|
||||
|
|
275
src/components/group/GroupPickerDialog.tsx
Normal file
275
src/components/group/GroupPickerDialog.tsx
Normal file
|
@ -0,0 +1,275 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Button,
|
||||
DataList,
|
||||
DataListAction,
|
||||
DataListCell,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
} from "@patternfly/react-core";
|
||||
import { AngleRightIcon } from "@patternfly/react-icons";
|
||||
|
||||
import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
|
||||
import { PaginatingTableToolbar } from "../table-toolbar/PaginatingTableToolbar";
|
||||
|
||||
export type GroupPickerDialogProps = {
|
||||
id?: string;
|
||||
type: "selectOne" | "selectMany";
|
||||
filterGroups?: string[];
|
||||
text: { title: string; ok: string };
|
||||
onConfirm: (groups: GroupRepresentation[]) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type SelectableGroup = GroupRepresentation & {
|
||||
checked?: boolean;
|
||||
};
|
||||
|
||||
export const GroupPickerDialog = ({
|
||||
id,
|
||||
type,
|
||||
filterGroups,
|
||||
text,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: GroupPickerDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const adminClient = useAdminClient();
|
||||
const [selectedRows, setSelectedRows] = useState<SelectableGroup[]>([]);
|
||||
|
||||
const [navigation, setNavigation] = useState<SelectableGroup[]>([]);
|
||||
const [groups, setGroups] = useState<SelectableGroup[]>([]);
|
||||
const [filtered, setFiltered] = useState<GroupRepresentation[]>();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [joinedGroups, setJoinedGroups] = useState<GroupRepresentation[]>([]);
|
||||
const [groupId, setGroupId] = useState<string>();
|
||||
|
||||
const [max, setMax] = useState(10);
|
||||
const [first, setFirst] = useState(0);
|
||||
|
||||
const currentGroup = () => navigation[navigation.length - 1];
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const allGroups = await adminClient.groups.find();
|
||||
|
||||
if (groupId) {
|
||||
const group = await adminClient.groups.findOne({ id: groupId });
|
||||
return { group, groups: group.subGroups! };
|
||||
} else if (id) {
|
||||
const existingUserGroups = await adminClient.users.listGroups({
|
||||
id,
|
||||
});
|
||||
return {
|
||||
groups: allGroups,
|
||||
existingUserGroups,
|
||||
};
|
||||
} else
|
||||
return {
|
||||
groups: allGroups,
|
||||
};
|
||||
},
|
||||
async ({ group: selectedGroup, groups, existingUserGroups }) => {
|
||||
setJoinedGroups(existingUserGroups || []);
|
||||
if (selectedGroup) {
|
||||
setNavigation([...navigation, selectedGroup]);
|
||||
}
|
||||
|
||||
groups.forEach((group: SelectableGroup) => {
|
||||
group.checked = !!selectedRows.find((r) => r.id === group.id);
|
||||
});
|
||||
setFiltered(undefined);
|
||||
setFilter("");
|
||||
setFirst(0);
|
||||
setMax(10);
|
||||
setGroups(
|
||||
filterGroups
|
||||
? [
|
||||
...groups.filter(
|
||||
(row) => filterGroups && !filterGroups.includes(row.name!)
|
||||
),
|
||||
]
|
||||
: groups
|
||||
);
|
||||
},
|
||||
[groupId]
|
||||
);
|
||||
|
||||
const isRowDisabled = (row?: GroupRepresentation) => {
|
||||
return !!joinedGroups.find((group) => group.id === row?.id);
|
||||
};
|
||||
|
||||
const hasSubgroups = (group: GroupRepresentation) => {
|
||||
return group.subGroups!.length !== 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
variant={ModalVariant.small}
|
||||
title={t(text.title, {
|
||||
group1: filterGroups && filterGroups[0],
|
||||
group2: currentGroup() ? currentGroup().name : t("root"),
|
||||
})}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
actions={[
|
||||
<Button
|
||||
data-testid={`${text.ok}-button`}
|
||||
key="confirm"
|
||||
variant="primary"
|
||||
form="group-form"
|
||||
onClick={() => {
|
||||
onConfirm(type === "selectMany" ? selectedRows : [currentGroup()]);
|
||||
}}
|
||||
isDisabled={type === "selectMany" && selectedRows.length === 0}
|
||||
>
|
||||
{t(text.ok)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Breadcrumb>
|
||||
{navigation.length > 0 && (
|
||||
<BreadcrumbItem key="home">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setGroupId(undefined);
|
||||
setNavigation([]);
|
||||
}}
|
||||
>
|
||||
{t("groups")}
|
||||
</Button>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{navigation.map((group, i) => (
|
||||
<BreadcrumbItem key={i}>
|
||||
{navigation.length - 1 !== i && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setGroupId(group.id);
|
||||
setNavigation([...navigation].slice(0, i));
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
)}
|
||||
{navigation.length - 1 === i && <>{group.name}</>}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
|
||||
<PaginatingTableToolbar
|
||||
count={(filtered || groups).slice(first, first + max).length}
|
||||
first={first}
|
||||
max={max}
|
||||
onNextClick={setFirst}
|
||||
onPreviousClick={setFirst}
|
||||
onPerPageSelect={(first, max) => {
|
||||
setFirst(first);
|
||||
setMax(max);
|
||||
}}
|
||||
inputGroupName={"common:search"}
|
||||
inputGroupOnEnter={(search) => {
|
||||
setFilter(search);
|
||||
setFirst(0);
|
||||
setMax(10);
|
||||
setFiltered(
|
||||
groups.filter((group) =>
|
||||
group.name?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
);
|
||||
}}
|
||||
inputGroupPlaceholder={t("users:searchForGroups")}
|
||||
>
|
||||
<DataList aria-label={t("groups")} isCompact>
|
||||
{(filtered || groups)
|
||||
.slice(first, first + max)
|
||||
.map((group: SelectableGroup) => (
|
||||
<DataListItem
|
||||
aria-labelledby={group.name}
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
onClick={(e) => {
|
||||
if (type === "selectOne") {
|
||||
setGroupId(group.id);
|
||||
} else if (
|
||||
hasSubgroups(group) &&
|
||||
(e.target as HTMLInputElement).type !== "checkbox"
|
||||
) {
|
||||
setGroupId(group.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DataListItemRow
|
||||
className={`join-group-dialog-row-${
|
||||
isRowDisabled(group) ? "m-disabled" : ""
|
||||
}`}
|
||||
data-testid={group.name}
|
||||
>
|
||||
{type === "selectMany" && (
|
||||
<DataListCheck
|
||||
className="join-group-modal-check"
|
||||
data-testid={`${group.name}-check`}
|
||||
checked={group.checked}
|
||||
isDisabled={isRowDisabled(group)}
|
||||
onChange={(checked) => {
|
||||
group.checked = checked;
|
||||
let newSelectedRows: SelectableGroup[] = [];
|
||||
if (!group.checked) {
|
||||
newSelectedRows = selectedRows.filter(
|
||||
(r) => r.id !== group.id
|
||||
);
|
||||
} else if (group.checked) {
|
||||
newSelectedRows = [...selectedRows, group];
|
||||
}
|
||||
|
||||
setSelectedRows(newSelectedRows);
|
||||
}}
|
||||
aria-labelledby="data-list-check"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key={`name-${group.id}`}>
|
||||
<>{group.name}</>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-labelledby={`select-${group.name}`}
|
||||
id={`select-${group.name}`}
|
||||
aria-label={t("groupName")}
|
||||
isPlainButtonAction
|
||||
>
|
||||
{(hasSubgroups(group) || type === "selectOne") && (
|
||||
<Button isDisabled variant="link">
|
||||
<AngleRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
))}
|
||||
{(filtered || groups).length === 0 && filter === "" && (
|
||||
<ListEmptyState
|
||||
hasIcon={false}
|
||||
message={t("groups:moveGroupEmpty")}
|
||||
instructions={t("groups:moveGroupEmptyInstructions")}
|
||||
/>
|
||||
)}
|
||||
</DataList>
|
||||
</PaginatingTableToolbar>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -53,7 +53,9 @@ export const ListEmptyState = ({
|
|||
<EmptyStateBody>{instructions}</EmptyStateBody>
|
||||
{primaryActionText && (
|
||||
<Button
|
||||
data-testid="empty-primary-action"
|
||||
data-testid={`${message
|
||||
.replace(/\W+/g, "-")
|
||||
.toLowerCase()}-empty-action`}
|
||||
variant="primary"
|
||||
onClick={onPrimaryAction}
|
||||
>
|
||||
|
|
|
@ -39,7 +39,7 @@ exports[`<ListEmptyState /> render 1`] = `
|
|||
data-ouia-component-id="OUIA-Generated-Button-primary-1"
|
||||
data-ouia-component-type="PF4/Button"
|
||||
data-ouia-safe="true"
|
||||
data-testid="empty-primary-action"
|
||||
data-testid="no-things-empty-action"
|
||||
type="button"
|
||||
>
|
||||
Add it now!
|
||||
|
|
|
@ -19,6 +19,7 @@ import "./role-mapping.css";
|
|||
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../alert/Alerts";
|
||||
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
|
||||
|
||||
export type CompositeRole = RoleRepresentation & {
|
||||
parent: RoleRepresentation;
|
||||
|
@ -207,6 +208,14 @@ export const RoleMapping = ({
|
|||
cellFormatters: [emptyFormatter()],
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
<ListEmptyState
|
||||
message={t("noRoles")}
|
||||
instructions={t("noRolesInstructions")}
|
||||
primaryActionText={t("assignRole")}
|
||||
onPrimaryAction={() => setShowAssign(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -289,12 +289,12 @@ export function KeycloakDataTable<T>({
|
|||
useFetch(
|
||||
async () => {
|
||||
setLoading(true);
|
||||
return unPaginatedData || (await loader(first, max, search));
|
||||
return unPaginatedData || (await loader(first, max + 1, search));
|
||||
},
|
||||
(data) => {
|
||||
if (!isPaginated) {
|
||||
setUnPaginatedData(data);
|
||||
data = data.slice(first, first + max);
|
||||
data = data.slice(first, first + max + 1);
|
||||
}
|
||||
|
||||
const result = convertToColumns(data);
|
||||
|
@ -360,8 +360,9 @@ export function KeycloakDataTable<T>({
|
|||
onSelect!(selectedRows);
|
||||
};
|
||||
|
||||
const data = filteredData || rows;
|
||||
|
||||
const onCollapse = (isOpen: boolean, rowIndex: number) => {
|
||||
const data = filteredData || rows;
|
||||
(data![rowIndex] as Row<T>).isOpen = isOpen;
|
||||
setRows([...data!]);
|
||||
};
|
||||
|
@ -369,9 +370,9 @@ export function KeycloakDataTable<T>({
|
|||
return (
|
||||
<>
|
||||
{!rows && loading && <Loading />}
|
||||
{rows && (
|
||||
{((data && data.length > 0) || search !== "" || !emptyState) && (
|
||||
<PaginatingTableToolbar
|
||||
count={rows.length}
|
||||
count={data?.length || 0}
|
||||
first={first}
|
||||
max={max}
|
||||
onNextClick={setFirst}
|
||||
|
@ -388,7 +389,7 @@ export function KeycloakDataTable<T>({
|
|||
searchTypeComponent={searchTypeComponent}
|
||||
toolbarItem={toolbarItem}
|
||||
>
|
||||
{!loading && (filteredData || rows).length > 0 && (
|
||||
{!loading && data && data.length > 0 && (
|
||||
<DataTable
|
||||
{...props}
|
||||
canSelectAll={canSelectAll}
|
||||
|
@ -396,7 +397,7 @@ export function KeycloakDataTable<T>({
|
|||
onCollapse={detailColumns ? onCollapse : undefined}
|
||||
actions={convertAction()}
|
||||
actionResolver={actionResolver}
|
||||
rows={filteredData || rows}
|
||||
rows={data}
|
||||
columns={columns}
|
||||
isNotCompact={isNotCompact}
|
||||
isRadio={isRadio}
|
||||
|
@ -404,8 +405,8 @@ export function KeycloakDataTable<T>({
|
|||
/>
|
||||
)}
|
||||
{!loading &&
|
||||
(filteredData || rows).length === 0 &&
|
||||
search !== "" &&
|
||||
(!data || data.length === 0) &&
|
||||
(search !== "" || !emptyState) &&
|
||||
searchPlaceholderKey && (
|
||||
<ListEmptyState
|
||||
hasIcon={true}
|
||||
|
@ -420,7 +421,7 @@ export function KeycloakDataTable<T>({
|
|||
)}
|
||||
<>
|
||||
{!loading &&
|
||||
(filteredData || rows)?.length === 0 &&
|
||||
(!data || data?.length === 0) &&
|
||||
search === "" &&
|
||||
emptyState}
|
||||
</>
|
||||
|
|
|
@ -46,10 +46,10 @@ export const PaginatingTableToolbar = ({
|
|||
isCompact
|
||||
toggleTemplate={({ firstIndex, lastIndex }: ToggleTemplateProps) => (
|
||||
<b>
|
||||
{firstIndex} - {lastIndex! - (count < max ? 1 : 0)}
|
||||
{firstIndex} - {lastIndex}
|
||||
</b>
|
||||
)}
|
||||
itemCount={count + page * max + (count <= max ? 1 : 0)}
|
||||
itemCount={count + page * max}
|
||||
page={page + 1}
|
||||
perPage={max}
|
||||
onNextClick={(_, p) => onNextClick((p - 1) * max)}
|
||||
|
|
|
@ -41,7 +41,9 @@ export function useFetch<T>(
|
|||
useEffect(() => {
|
||||
adminClientCall()
|
||||
.then((result) => {
|
||||
callback(result);
|
||||
if (!source.token.reason) {
|
||||
callback(result);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!axios.isCancel(error)) {
|
||||
|
|
|
@ -22,7 +22,7 @@ import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable
|
|||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
import { GroupsModal } from "./GroupsModal";
|
||||
import { getLastId } from "./groupIdUtils";
|
||||
import { MoveGroupDialog } from "./MoveGroupDialog";
|
||||
import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
|
||||
import { useSubGroups } from "./SubGroupsContext";
|
||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
|
||||
|
@ -223,15 +223,23 @@ export const GroupTable = () => {
|
|||
/>
|
||||
)}
|
||||
{move && (
|
||||
<MoveGroupDialog
|
||||
group={move}
|
||||
<GroupPickerDialog
|
||||
type="selectOne"
|
||||
filterGroups={[move.name!]}
|
||||
text={{
|
||||
title: "groups:moveToGroup",
|
||||
ok: "groups:moveHere",
|
||||
}}
|
||||
onClose={() => setMove(undefined)}
|
||||
onMove={async (id) => {
|
||||
onConfirm={async (group) => {
|
||||
delete move.membersLength;
|
||||
try {
|
||||
try {
|
||||
if (id) {
|
||||
await adminClient.groups.setOrCreateChild({ id }, move);
|
||||
if (group[0].id) {
|
||||
await adminClient.groups.setOrCreateChild(
|
||||
{ id: group[0].id },
|
||||
move
|
||||
);
|
||||
} else {
|
||||
await adminClient.groups.create(move);
|
||||
}
|
||||
|
|
|
@ -1,200 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
DataList,
|
||||
DataListAction,
|
||||
DataListCell,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
InputGroup,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
TextInput,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { AngleRightIcon, SearchIcon } from "@patternfly/react-icons";
|
||||
|
||||
import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||
import { useFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
|
||||
type MoveGroupDialogProps = {
|
||||
group: GroupRepresentation;
|
||||
onClose: () => void;
|
||||
onMove: (groupId: string | undefined) => void;
|
||||
};
|
||||
|
||||
export const MoveGroupDialog = ({
|
||||
group,
|
||||
onClose,
|
||||
onMove,
|
||||
}: MoveGroupDialogProps) => {
|
||||
const { t } = useTranslation("groups");
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
|
||||
const [navigation, setNavigation] = useState<GroupRepresentation[]>([]);
|
||||
const [groups, setGroups] = useState<GroupRepresentation[]>([]);
|
||||
const [filtered, setFiltered] = useState<GroupRepresentation[]>();
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const [id, setId] = useState<string>();
|
||||
const currentGroup = () => navigation[navigation.length - 1];
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
if (id) {
|
||||
const group = await adminClient.groups.findOne({ id });
|
||||
return { group, groups: group.subGroups! };
|
||||
} else {
|
||||
return { groups: await adminClient.groups.find() };
|
||||
}
|
||||
},
|
||||
({ group: selectedGroup, groups }) => {
|
||||
if (selectedGroup) setNavigation([...navigation, selectedGroup]);
|
||||
setGroups(groups.filter((g) => g.id !== group.id));
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
variant={ModalVariant.large}
|
||||
title={t("moveToGroup", {
|
||||
group1: group.name,
|
||||
group2: currentGroup() ? currentGroup().name : t("root"),
|
||||
})}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
actions={[
|
||||
<Button
|
||||
data-testid="joinGroup"
|
||||
key="confirm"
|
||||
variant="primary"
|
||||
form="group-form"
|
||||
onClick={() => onMove(currentGroup()?.id)}
|
||||
>
|
||||
{t("moveHere")}
|
||||
</Button>,
|
||||
<Button
|
||||
data-testid="moveCancel"
|
||||
key="cancel"
|
||||
variant="link"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem key="home">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setId(undefined);
|
||||
setNavigation([]);
|
||||
}}
|
||||
>
|
||||
{t("groups")}
|
||||
</Button>
|
||||
</BreadcrumbItem>
|
||||
{navigation.map((group, i) => (
|
||||
<BreadcrumbItem key={i}>
|
||||
{navigation.length - 1 !== i && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setId(group.id);
|
||||
setNavigation([...navigation].slice(0, i));
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
)}
|
||||
{navigation.length - 1 === i && <>{group.name}</>}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
|
||||
<Toolbar>
|
||||
<ToolbarContent>
|
||||
<ToolbarItem>
|
||||
<InputGroup>
|
||||
<TextInput
|
||||
type="search"
|
||||
aria-label={t("common:search")}
|
||||
placeholder={t("searchForGroups")}
|
||||
onChange={(value) => {
|
||||
if (value === "") {
|
||||
setFiltered(undefined);
|
||||
}
|
||||
setFilter(value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={t("common:search")}
|
||||
onClick={() =>
|
||||
setFiltered(
|
||||
groups.filter((group) =>
|
||||
group.name?.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</ToolbarItem>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
<DataList
|
||||
onSelectDataListItem={(value) => setId(value)}
|
||||
aria-label={t("groups")}
|
||||
isCompact
|
||||
>
|
||||
{(filtered || groups).map((group) => (
|
||||
<DataListItem
|
||||
aria-labelledby={group.name}
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
>
|
||||
<DataListItemRow data-testid={group.name}>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key={`name-${group.id}`}>
|
||||
<>{group.name}</>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-labelledby={`select-${group.name}`}
|
||||
id={`select-${group.name}`}
|
||||
aria-label={t("groupName")}
|
||||
isPlainButtonAction
|
||||
>
|
||||
<Button isDisabled variant="link">
|
||||
<AngleRightIcon />
|
||||
</Button>
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
))}
|
||||
{(filtered || groups).length === 0 && (
|
||||
<ListEmptyState
|
||||
hasIcon={false}
|
||||
message={t("moveGroupEmpty")}
|
||||
instructions={t("moveGroupEmptyInstructions")}
|
||||
/>
|
||||
)}
|
||||
</DataList>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
|
@ -21,6 +21,7 @@ import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable
|
|||
import { useAdminClient } from "../context/auth/AdminClient";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
import { useSubGroups } from "./SubGroupsContext";
|
||||
|
||||
type SearchGroup = GroupRepresentation & {
|
||||
link?: string;
|
||||
|
@ -33,11 +34,13 @@ export const SearchGroups = () => {
|
|||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchTerms, setSearchTerms] = useState<string[]>([]);
|
||||
const [searchCount, setSearchCount] = useState(0);
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
const refresh = () => setKey(new Date().getTime());
|
||||
|
||||
const { setSubGroups } = useSubGroups();
|
||||
useEffect(() => setSubGroups([{ name: t("searchGroups") }]), []);
|
||||
|
||||
const deleteTerm = (id: string) => {
|
||||
const index = searchTerms.indexOf(id);
|
||||
searchTerms.splice(index, 1);
|
||||
|
@ -51,13 +54,16 @@ export const SearchGroups = () => {
|
|||
refresh();
|
||||
};
|
||||
|
||||
const GroupNameCell = (group: SearchGroup) => (
|
||||
<>
|
||||
<Link key={group.id} to={`/${realm}/groups/${group.link}`}>
|
||||
{group.name}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
const GroupNameCell = (group: SearchGroup) => {
|
||||
setSubGroups([{ name: t("searchGroups"), id: "search" }, group]);
|
||||
return (
|
||||
<>
|
||||
<Link key={group.id} to={`/${realm}/groups/search/${group.link}`}>
|
||||
{group.name}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const flatten = (
|
||||
groups: GroupRepresentation[],
|
||||
|
@ -92,7 +98,6 @@ export const SearchGroups = () => {
|
|||
}
|
||||
}
|
||||
|
||||
setSearchCount(result.length);
|
||||
return result;
|
||||
};
|
||||
|
||||
|
@ -136,8 +141,6 @@ export const SearchGroups = () => {
|
|||
))}
|
||||
</ChipGroup>
|
||||
</Form>
|
||||
</PageSection>
|
||||
<PageSection variant={searchCount === 0 ? "light" : "default"}>
|
||||
<KeycloakDataTable
|
||||
key={key}
|
||||
ariaLabelKey="groups:groups"
|
||||
|
|
|
@ -13,6 +13,7 @@ import groups from "./groups/messages.json";
|
|||
import realm from "./realm/messages.json";
|
||||
import roles from "./realm-roles/messages.json";
|
||||
import users from "./user/messages.json";
|
||||
import usersHelp from "./user/help.json";
|
||||
import sessions from "./sessions/messages.json";
|
||||
import events from "./events/messages.json";
|
||||
import realmSettings from "./realm-settings/messages.json";
|
||||
|
@ -40,6 +41,7 @@ const initOptions = {
|
|||
...roles,
|
||||
...groups,
|
||||
...users,
|
||||
...usersHelp,
|
||||
...sessions,
|
||||
...userFederation,
|
||||
...events,
|
||||
|
|
|
@ -273,16 +273,18 @@ export const KeysListTab = ({ realmComponents }: KeysListTabProps) => {
|
|||
},
|
||||
]}
|
||||
emptyState={
|
||||
<ListEmptyState
|
||||
hasIcon={true}
|
||||
message={t("realm-settings:noKeys")}
|
||||
instructions={
|
||||
t(`realm-settings:noKeysDescription`) +
|
||||
`${filterType.toLocaleLowerCase()}.`
|
||||
}
|
||||
primaryActionText={t("createRole")}
|
||||
onPrimaryAction={goToCreate}
|
||||
/>
|
||||
filterType ? undefined : (
|
||||
<ListEmptyState
|
||||
hasIcon={true}
|
||||
message={t("realm-settings:noKeys")}
|
||||
instructions={
|
||||
t(`realm-settings:noKeysDescription`) +
|
||||
`${filterType.toLocaleLowerCase()}.`
|
||||
}
|
||||
primaryActionText={t("createRole")}
|
||||
onPrimaryAction={goToCreate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</PageSection>
|
||||
|
|
|
@ -127,6 +127,7 @@ export const LdapMapperList = () => {
|
|||
message={t("common:emptyMappers")}
|
||||
instructions={t("common:emptyMappersInstructions")}
|
||||
primaryActionText={t("common:emptyPrimaryAction")}
|
||||
onPrimaryAction={() => history.push(`${url}/new`)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -1,262 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
DataList,
|
||||
DataListAction,
|
||||
DataListCell,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
InputGroup,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
TextInput,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||
import { AngleRightIcon, SearchIcon } from "@patternfly/react-icons";
|
||||
import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
export type JoinGroupDialogProps = {
|
||||
open: boolean;
|
||||
toggleDialog: () => void;
|
||||
onClose: () => void;
|
||||
username?: string;
|
||||
onConfirm: (newGroups: Group[]) => void;
|
||||
chips?: any;
|
||||
};
|
||||
|
||||
type Group = GroupRepresentation & {
|
||||
checked?: boolean;
|
||||
};
|
||||
|
||||
export const JoinGroupDialog = ({
|
||||
onClose,
|
||||
open,
|
||||
toggleDialog,
|
||||
onConfirm,
|
||||
username,
|
||||
chips,
|
||||
}: JoinGroupDialogProps) => {
|
||||
const { t } = useTranslation("roles");
|
||||
const adminClient = useAdminClient();
|
||||
const [selectedRows, setSelectedRows] = useState<Group[]>([]);
|
||||
|
||||
const [navigation, setNavigation] = useState<Group[]>([]);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [filtered, setFiltered] = useState<GroupRepresentation[]>();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [joinedGroups, setJoinedGroups] = useState<GroupRepresentation[]>([]);
|
||||
|
||||
const [groupId, setGroupId] = useState<string>();
|
||||
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const allGroups = await adminClient.groups.find();
|
||||
|
||||
if (groupId) {
|
||||
const group = await adminClient.groups.findOne({ id: groupId });
|
||||
return { group, groups: group.subGroups! };
|
||||
} else if (id) {
|
||||
const existingUserGroups = await adminClient.users.listGroups({
|
||||
id,
|
||||
});
|
||||
setJoinedGroups(existingUserGroups);
|
||||
return {
|
||||
groups: allGroups,
|
||||
};
|
||||
} else
|
||||
return {
|
||||
groups: allGroups,
|
||||
};
|
||||
},
|
||||
async ({ group: selectedGroup, groups }) => {
|
||||
if (selectedGroup) {
|
||||
setNavigation([...navigation, selectedGroup]);
|
||||
}
|
||||
|
||||
groups.forEach((group: Group) => {
|
||||
group.checked = !!selectedRows.find((r) => r.id === group.id);
|
||||
});
|
||||
id
|
||||
? setGroups(groups)
|
||||
: setGroups([...groups.filter((row) => !chips.includes(row.name))]);
|
||||
},
|
||||
[groupId]
|
||||
);
|
||||
|
||||
const isRowDisabled = (row?: GroupRepresentation) => {
|
||||
return !!joinedGroups.find((group) => group.id === row?.id);
|
||||
};
|
||||
|
||||
const hasSubgroups = (group: GroupRepresentation) => {
|
||||
return group.subGroups!.length !== 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
variant={ModalVariant.small}
|
||||
title={
|
||||
username ? t("users:joinGroupsFor") + username : t("users:selectGroups")
|
||||
}
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
actions={[
|
||||
<Button
|
||||
data-testid="join-button"
|
||||
key="confirm"
|
||||
variant="primary"
|
||||
form="group-form"
|
||||
onClick={() => {
|
||||
toggleDialog();
|
||||
onConfirm(selectedRows);
|
||||
}}
|
||||
isDisabled={selectedRows.length === 0}
|
||||
>
|
||||
{t("users:Join")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem key="home">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setGroupId(undefined);
|
||||
setNavigation([]);
|
||||
}}
|
||||
>
|
||||
{t("groups")}
|
||||
</Button>
|
||||
</BreadcrumbItem>
|
||||
{navigation.map((group, i) => (
|
||||
<BreadcrumbItem key={i}>
|
||||
{navigation.length - 1 !== i && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setGroupId(group.id);
|
||||
setNavigation([...navigation].slice(0, i));
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
)}
|
||||
{navigation.length - 1 === i && <>{group.name}</>}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
|
||||
<Toolbar>
|
||||
<ToolbarContent>
|
||||
<ToolbarItem>
|
||||
<InputGroup>
|
||||
<TextInput
|
||||
type="search"
|
||||
aria-label={t("common:search")}
|
||||
placeholder={t("users:searchForGroups")}
|
||||
onChange={(value) => {
|
||||
if (value === "") {
|
||||
setFiltered(undefined);
|
||||
}
|
||||
setFilter(value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={t("common:search")}
|
||||
onClick={() =>
|
||||
setFiltered(
|
||||
groups.filter((group) =>
|
||||
group.name?.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</ToolbarItem>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
<DataList aria-label={t("groups")} isCompact>
|
||||
{(filtered || groups).map((group: Group) => (
|
||||
<DataListItem
|
||||
aria-labelledby={group.name}
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
onClick={
|
||||
hasSubgroups(group)
|
||||
? (e) => {
|
||||
if ((e.target as HTMLInputElement).type !== "checkbox") {
|
||||
setGroupId(group.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DataListItemRow
|
||||
className={`join-group-dialog-row-${
|
||||
isRowDisabled(group) ? "m-disabled" : ""
|
||||
}`}
|
||||
data-testid={group.name}
|
||||
>
|
||||
<DataListCheck
|
||||
className="join-group-modal-check"
|
||||
data-testid={`${group.name}-check`}
|
||||
isChecked={group.checked}
|
||||
isDisabled={isRowDisabled(group)}
|
||||
onChange={(checked, e) => {
|
||||
group.checked = (e.target as HTMLInputElement).checked;
|
||||
let newSelectedRows: Group[];
|
||||
if (!group.checked) {
|
||||
newSelectedRows = selectedRows.filter(
|
||||
(r) => r.id !== group.id
|
||||
);
|
||||
} else if (group.checked) {
|
||||
newSelectedRows = [...selectedRows, group];
|
||||
}
|
||||
|
||||
setSelectedRows(newSelectedRows!);
|
||||
}}
|
||||
aria-labelledby="data-list-check"
|
||||
/>
|
||||
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key={`name-${group.id}`}>
|
||||
<>{group.name}</>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-labelledby={`select-${group.name}`}
|
||||
id={`select-${group.name}`}
|
||||
aria-label={t("groupName")}
|
||||
isPlainButtonAction
|
||||
>
|
||||
{hasSubgroups(group) ? (
|
||||
<Button isDisabled variant="link">
|
||||
<AngleRightIcon />
|
||||
</Button>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
))}
|
||||
</DataList>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -21,10 +21,10 @@ import { HelpItem } from "../components/help-enabler/HelpItem";
|
|||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import { useFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||
import moment from "moment";
|
||||
import { JoinGroupDialog } from "./JoinGroupDialog";
|
||||
import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||
import { useAlerts } from "../components/alert/Alerts";
|
||||
import { emailRegexPattern } from "../util";
|
||||
import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
|
||||
|
||||
export type UserFormProps = {
|
||||
form: UseFormMethods<UserRepresentation>;
|
||||
|
@ -52,8 +52,7 @@ export const UserForm = ({
|
|||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const watchUsernameInput = watch("username");
|
||||
const [timestamp, setTimestamp] = useState(null);
|
||||
const [chips, setChips] = useState<(string | undefined)[]>([]);
|
||||
const [user, setUser] = useState<UserRepresentation>();
|
||||
const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>(
|
||||
[]
|
||||
);
|
||||
|
@ -67,19 +66,18 @@ export const UserForm = ({
|
|||
if (editMode) return await adminClient.users.findOne({ id: id });
|
||||
},
|
||||
(user) => {
|
||||
if (user) setupForm(user);
|
||||
if (user) {
|
||||
setupForm(user);
|
||||
setUser(user);
|
||||
}
|
||||
},
|
||||
[chips]
|
||||
[selectedGroups]
|
||||
);
|
||||
|
||||
const setupForm = (user: UserRepresentation) => {
|
||||
reset();
|
||||
Object.entries(user).map((entry) => {
|
||||
if (entry[0] == "createdTimestamp") {
|
||||
setTimestamp(entry[1]);
|
||||
} else {
|
||||
setValue(entry[0], entry[1]);
|
||||
}
|
||||
setValue(entry[0], entry[1]);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -103,33 +101,22 @@ export const UserForm = ({
|
|||
};
|
||||
|
||||
const deleteItem = (id: string) => {
|
||||
const copyOfChips = chips;
|
||||
const copyOfGroups = selectedGroups;
|
||||
|
||||
setChips(copyOfChips.filter((item) => item !== id));
|
||||
setSelectedGroups(copyOfGroups.filter((item) => item.name !== id));
|
||||
setSelectedGroups(selectedGroups.filter((item) => item.name !== id));
|
||||
onGroupsUpdate(selectedGroups);
|
||||
};
|
||||
|
||||
const addChips = async (groups: GroupRepresentation[]): Promise<void> => {
|
||||
const newSelectedGroups = groups;
|
||||
|
||||
const newGroupNames: (string | undefined)[] = newSelectedGroups!.map(
|
||||
(item) => item.name
|
||||
);
|
||||
setChips([...chips!, ...newGroupNames]);
|
||||
setSelectedGroups([...selectedGroups!, ...newSelectedGroups]);
|
||||
setSelectedGroups([...selectedGroups!, ...groups]);
|
||||
onGroupsUpdate([...selectedGroups!, ...groups]);
|
||||
};
|
||||
|
||||
onGroupsUpdate(selectedGroups);
|
||||
|
||||
const addGroups = async (groups: GroupRepresentation[]): Promise<void> => {
|
||||
const newGroups = groups;
|
||||
|
||||
newGroups.forEach(async (group) => {
|
||||
try {
|
||||
await adminClient.users.addToGroup({
|
||||
id: id,
|
||||
id,
|
||||
groupId: group.id!,
|
||||
});
|
||||
addAlert(t("users:addedGroupMembership"), AlertVariant.success);
|
||||
|
@ -154,44 +141,34 @@ export const UserForm = ({
|
|||
className="pf-u-mt-lg"
|
||||
>
|
||||
{open && (
|
||||
<JoinGroupDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(!open)}
|
||||
onConfirm={editMode ? addGroups : addChips}
|
||||
toggleDialog={() => toggleModal()}
|
||||
chips={chips}
|
||||
<GroupPickerDialog
|
||||
type="selectMany"
|
||||
text={{
|
||||
title: "users:selectGroups",
|
||||
ok: "users:join",
|
||||
}}
|
||||
onConfirm={(groups) => {
|
||||
editMode ? addGroups(groups) : addChips(groups);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
filterGroups={selectedGroups.map((g) => g.name!)}
|
||||
/>
|
||||
)}
|
||||
{editMode ? (
|
||||
{editMode && user ? (
|
||||
<>
|
||||
<FormGroup
|
||||
label={t("common:id")}
|
||||
fieldId="kc-id"
|
||||
isRequired
|
||||
validated={errors.id ? "error" : "default"}
|
||||
helperTextInvalid={t("common:required")}
|
||||
>
|
||||
<TextInput
|
||||
ref={register({ required: !editMode })}
|
||||
type="text"
|
||||
id="kc-id"
|
||||
name="id"
|
||||
isReadOnly={editMode}
|
||||
/>
|
||||
<FormGroup label={t("common:id")} fieldId="kc-id" isRequired>
|
||||
<TextInput id={user.id} value={user.id} type="text" isReadOnly />
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("createdAt")}
|
||||
fieldId="kc-created-at"
|
||||
isRequired
|
||||
validated={errors.createdTimestamp ? "error" : "default"}
|
||||
helperTextInvalid={t("common:required")}
|
||||
>
|
||||
<FormGroup label={t("createdAt")} fieldId="kc-created-at" isRequired>
|
||||
<TextInput
|
||||
value={moment(timestamp).format("MM/DD/YY hh:MM:ss A")}
|
||||
value={moment(user.createdTimestamp).format(
|
||||
"MM/DD/YY hh:MM:ss A"
|
||||
)}
|
||||
type="text"
|
||||
id="kc-created-at"
|
||||
name="createdTimestamp"
|
||||
isReadOnly={editMode}
|
||||
isReadOnly
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
|
@ -235,7 +212,7 @@ export const UserForm = ({
|
|||
helperTextInvalid={t("common:required")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("emailVerifiedHelpText")}
|
||||
helpText="users-help:emailVerified"
|
||||
forLabel={t("emailVerified")}
|
||||
forID="email-verified"
|
||||
/>
|
||||
|
@ -256,7 +233,7 @@ export const UserForm = ({
|
|||
labelOff={t("common:off")}
|
||||
/>
|
||||
)}
|
||||
></Controller>
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("firstName")}
|
||||
|
@ -291,7 +268,7 @@ export const UserForm = ({
|
|||
fieldId="kc-enabled"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("disabledHelpText")}
|
||||
helpText="users-help:disabled"
|
||||
forLabel={t("enabled")}
|
||||
forID="enabled-label"
|
||||
/>
|
||||
|
@ -299,20 +276,19 @@ export const UserForm = ({
|
|||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
defaultValue={false}
|
||||
defaultValue={true}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
data-testid="user-enabled-switch"
|
||||
id={"kc-user-enabled"}
|
||||
isDisabled={false}
|
||||
onChange={(value) => onChange(value)}
|
||||
isChecked={value}
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
/>
|
||||
)}
|
||||
></Controller>
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("requiredUserActions")}
|
||||
|
@ -321,7 +297,7 @@ export const UserForm = ({
|
|||
helperTextInvalid={t("common:required")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("requiredUserActionsHelpText")}
|
||||
helpText="users-help:requiredUserActions"
|
||||
forLabel={t("requiredUserActions")}
|
||||
forID="required-user-actions-label"
|
||||
/>
|
||||
|
@ -368,9 +344,9 @@ export const UserForm = ({
|
|||
helperTextInvalid={t("common:required")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("requiredUserActionsHelpText")}
|
||||
forLabel={t("requiredUserActions")}
|
||||
forID="required-user-actions-label"
|
||||
helpText="users-help:groups"
|
||||
forLabel={t("common:groups")}
|
||||
forID="kc-join-groups-button"
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
@ -383,12 +359,12 @@ export const UserForm = ({
|
|||
<>
|
||||
<InputGroup>
|
||||
<ChipGroup categoryName={" "}>
|
||||
{chips.map((currentChip) => (
|
||||
{selectedGroups.map((currentChip) => (
|
||||
<Chip
|
||||
key={currentChip}
|
||||
onClick={() => deleteItem(currentChip!)}
|
||||
key={currentChip.id}
|
||||
onClick={() => deleteItem(currentChip.name!)}
|
||||
>
|
||||
{currentChip}
|
||||
{currentChip.path}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
|
@ -414,14 +390,16 @@ export const UserForm = ({
|
|||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
{!editMode ? t("common:Create") : t("common:Save")}
|
||||
{editMode ? t("common:save") : t("common:create")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="cancel-create-user"
|
||||
onClick={() => history.push(`/${realm}/users`)}
|
||||
onClick={() =>
|
||||
editMode ? setupForm(user!) : history.push(`/${realm}/users`)
|
||||
}
|
||||
variant="link"
|
||||
>
|
||||
{t("common:cancel")}
|
||||
{editMode ? t("common:revert") : t("common:cancel")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormAccess>
|
||||
|
|
|
@ -19,7 +19,7 @@ import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentatio
|
|||
import { cellWidth } from "@patternfly/react-table";
|
||||
import _ from "lodash";
|
||||
import type UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
|
||||
import { JoinGroupDialog } from "./JoinGroupDialog";
|
||||
import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
|
||||
import { HelpContext } from "../components/help-enabler/HelpHeader";
|
||||
import { QuestionCircleIcon } from "@patternfly/react-icons";
|
||||
|
||||
|
@ -38,7 +38,7 @@ export type UserFormProps = {
|
|||
};
|
||||
|
||||
export const UserGroups = () => {
|
||||
const { t } = useTranslation("roles");
|
||||
const { t } = useTranslation("users");
|
||||
const { addAlert } = useAlerts();
|
||||
const [key, setKey] = useState(0);
|
||||
const refresh = () => setKey(new Date().getTime());
|
||||
|
@ -194,34 +194,19 @@ export const UserGroups = () => {
|
|||
return <>{group.name}</>;
|
||||
};
|
||||
|
||||
const JoinGroupButtonRenderer = (group: GroupRepresentation) => {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => joinGroup(group)} variant="link">
|
||||
{t("users:joinGroup")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleModal = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const joinGroup = (group: GroupRepresentation) => {
|
||||
setSelectedGroup(group);
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||
titleKey: t("users:leaveGroup", {
|
||||
titleKey: t("leaveGroup", {
|
||||
name: selectedGroup?.name,
|
||||
}),
|
||||
messageKey: t("users:leaveGroupConfirmDialog", {
|
||||
messageKey: t("leaveGroupConfirmDialog", {
|
||||
groupname: selectedGroup?.name,
|
||||
username: username,
|
||||
}),
|
||||
continueButtonLabel: "users:leave",
|
||||
continueButtonLabel: "leave",
|
||||
continueButtonVariant: ButtonVariant.danger,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
|
@ -230,10 +215,10 @@ export const UserGroups = () => {
|
|||
groupId: selectedGroup!.id!,
|
||||
});
|
||||
refresh();
|
||||
addAlert(t("users:removedGroupMembership"), AlertVariant.success);
|
||||
addAlert(t("removedGroupMembership"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addAlert(
|
||||
t("users:removedGroupMembershipError", { error }),
|
||||
t("removedGroupMembershipError", { error }),
|
||||
AlertVariant.danger
|
||||
);
|
||||
}
|
||||
|
@ -246,25 +231,23 @@ export const UserGroups = () => {
|
|||
};
|
||||
|
||||
const LeaveButtonRenderer = (group: GroupRepresentation) => {
|
||||
if (
|
||||
const canLeaveGroup =
|
||||
directMembershipList.some((item) => item.id === group.id) ||
|
||||
directMembershipList.length === 0 ||
|
||||
isDirectMembership
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
isDirectMembership;
|
||||
return (
|
||||
<>
|
||||
{canLeaveGroup && (
|
||||
<Button
|
||||
data-testid={`leave-${group.name}`}
|
||||
onClick={() => leave(group)}
|
||||
variant="link"
|
||||
>
|
||||
{t("users:Leave")}
|
||||
{t("leave")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return <> </>;
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const addGroups = async (groups: GroupRepresentation[]): Promise<void> => {
|
||||
|
@ -278,10 +261,10 @@ export const UserGroups = () => {
|
|||
});
|
||||
setList(true);
|
||||
refresh();
|
||||
addAlert(t("users:addedGroupMembership"), AlertVariant.success);
|
||||
addAlert(t("addedGroupMembership"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addAlert(
|
||||
t("users:addedGroupMembershipError", { error }),
|
||||
t("addedGroupMembershipError", { error }),
|
||||
AlertVariant.danger
|
||||
);
|
||||
}
|
||||
|
@ -293,12 +276,18 @@ export const UserGroups = () => {
|
|||
<PageSection variant="light">
|
||||
<DeleteConfirm />
|
||||
{open && (
|
||||
<JoinGroupDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(!open)}
|
||||
onConfirm={addGroups}
|
||||
toggleDialog={() => toggleModal()}
|
||||
username={username}
|
||||
<GroupPickerDialog
|
||||
id={id}
|
||||
type="selectMany"
|
||||
text={{
|
||||
title: t("joinGroupsFor", { username }),
|
||||
ok: "users:join",
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
onConfirm={(groups) => {
|
||||
addGroups(groups);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<KeycloakDataTable
|
||||
|
@ -317,11 +306,10 @@ export const UserGroups = () => {
|
|||
onClick={toggleModal}
|
||||
data-testid="add-group-button"
|
||||
>
|
||||
{t("users:joinGroup")}
|
||||
{t("joinGroup")}
|
||||
</Button>
|
||||
{JoinGroupButtonRenderer}
|
||||
<Checkbox
|
||||
label={t("users:directMembership")}
|
||||
label={t("directMembership")}
|
||||
key="direct-membership-check"
|
||||
id="kc-direct-membership-checkbox"
|
||||
onChange={() => setDirectMembership(!isDirectMembership)}
|
||||
|
@ -332,7 +320,7 @@ export const UserGroups = () => {
|
|||
<Popover
|
||||
aria-label="Basic popover"
|
||||
position="bottom"
|
||||
bodyContent={<div>{t("users:whoWillAppearPopoverText")}</div>}
|
||||
bodyContent={<div>{t("whoWillAppearPopoverText")}</div>}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
|
@ -340,7 +328,7 @@ export const UserGroups = () => {
|
|||
key="who-will-appear-button"
|
||||
icon={<QuestionCircleIcon />}
|
||||
>
|
||||
{t("users:whoWillAppearLinkText")}
|
||||
{t("whoWillAppearLinkText")}
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
@ -356,7 +344,7 @@ export const UserGroups = () => {
|
|||
},
|
||||
{
|
||||
name: "path",
|
||||
displayKey: "users:Path",
|
||||
displayKey: "users:path",
|
||||
cellFormatters: [emptyFormatter()],
|
||||
transforms: [cellWidth(45)],
|
||||
},
|
||||
|
@ -372,8 +360,8 @@ export const UserGroups = () => {
|
|||
!search ? (
|
||||
<ListEmptyState
|
||||
hasIcon={true}
|
||||
message={t("users:noGroups")}
|
||||
instructions={t("users:noGroupsText")}
|
||||
message={t("noGroups")}
|
||||
instructions={t("noGroupsText")}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
|
|
|
@ -49,7 +49,7 @@ export const UsersTabs = () => {
|
|||
const save = async (user: UserRepresentation) => {
|
||||
try {
|
||||
if (id) {
|
||||
await adminClient.users.update({ id: user.id! }, user);
|
||||
await adminClient.users.update({ id }, user);
|
||||
addAlert(t("users:userSaved"), AlertVariant.success);
|
||||
} else {
|
||||
const createdUser = await adminClient.users.create(user);
|
||||
|
|
9
src/user/help.json
Normal file
9
src/user/help.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"users-help": {
|
||||
"disabled": "A disabled user cannot log in.",
|
||||
"emailVerified": "Has the user's email been verified?",
|
||||
"requiredUserActions": "Require an action when the user logs in. 'Verify email' sends an email to the user to verify their email address. 'Update profile' requires user to enter in new personal information. 'Update password' requires user to enter in a new password. 'Configure OTP' requires setup of a mobile password generator.",
|
||||
"groups": "Groups where the user has membership. To leave a group, select it and click Leave."
|
||||
|
||||
}
|
||||
}
|
|
@ -11,7 +11,8 @@
|
|||
"noGroupsText": "You haven't added this user to any groups. Join a group to get started.",
|
||||
"joinGroup": "Join Group",
|
||||
"joinGroups": "Join Groups",
|
||||
"joinGroupsFor": "Join groups for user ",
|
||||
"join": "Join",
|
||||
"joinGroupsFor": "Join groups for user {{username}}",
|
||||
"selectGroups": "Select groups to join",
|
||||
"searchForGroups": "Search for groups",
|
||||
"leave": "Leave",
|
||||
|
@ -33,13 +34,10 @@
|
|||
"firstName": "First name",
|
||||
"status": "Status",
|
||||
"disabled": "Disabled",
|
||||
"disabledHelpText": "A disabled user cannot log in.",
|
||||
"emailVerifiedHelpText": "Has the user's email been verified?",
|
||||
"emailInvalid": "You must enter a valid email.",
|
||||
"temporaryDisabled": "Temporarily disabled",
|
||||
"notVerified": "Not verified",
|
||||
"requiredUserActions": "Required user actions",
|
||||
"requiredUserActionsHelpText": "Require an action when the user logs in. 'Verify email' sends an email to the user to verify their email address. 'Update profile' requires user to enter in new personal information. 'Update password' requires user to enter in a new password. 'Configure OTP' requires setup of a mobile password generator.",
|
||||
"addUser": "Add user",
|
||||
"deleteUser": "Delete user",
|
||||
"deleteConfirm": "Delete user?",
|
||||
|
|
Loading…
Reference in a new issue