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:
Erik Jan de Wit 2021-06-16 13:35:03 +02:00 committed by GitHub
parent 1bf423b505
commit 2c9b77c425
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 482 additions and 654 deletions

View file

@ -133,7 +133,7 @@ describe("Clients test", function () {
}); });
it("Clustering", () => { it("Clustering", () => {
advancedTab.expandClusterNode().checkTestClusterAvailability(false); advancedTab.expandClusterNode();
advancedTab advancedTab
.clickRegisterNodeManually() .clickRegisterNodeManually()

View file

@ -44,7 +44,7 @@ describe("Group test", () => {
groupName += "_" + (Math.random() + 1).toString(36).substring(7); groupName += "_" + (Math.random() + 1).toString(36).substring(7);
groupModal groupModal
.open("empty-primary-action") .open("no-groups-in-this-realm-empty-action")
.fillGroupForm(groupName) .fillGroupForm(groupName)
.clickCreate(); .clickCreate();
@ -61,7 +61,7 @@ describe("Group test", () => {
it("Should rename group", () => { it("Should rename group", () => {
groupModal groupModal
.open("empty-primary-action") .open("no-groups-in-this-realm-empty-action")
.fillGroupForm(groupName) .fillGroupForm(groupName)
.clickCreate(); .clickCreate();
clickGroup(groupName); clickGroup(groupName);
@ -80,7 +80,7 @@ describe("Group test", () => {
it("Should move group", () => { it("Should move group", () => {
const targetGroupName = "target"; const targetGroupName = "target";
groupModal.open("empty-primary-action"); groupModal.open("no-groups-in-this-realm-empty-action");
groupModal.fillGroupForm(groupName).clickCreate(); groupModal.fillGroupForm(groupName).clickCreate();
groupModal.open().fillGroupForm(targetGroupName).clickCreate(); groupModal.open().fillGroupForm(targetGroupName).clickCreate();
@ -106,7 +106,7 @@ describe("Group test", () => {
it("Should move group to root", async () => { it("Should move group to root", async () => {
const groups = ["group1", "group2"]; const groups = ["group1", "group2"];
groupModal groupModal
.open("empty-primary-action") .open("no-groups-in-this-realm-empty-action")
.fillGroupForm(groups[0]) .fillGroupForm(groups[0])
.clickCreate(); .clickCreate();
groupModal.open().fillGroupForm(groups[1]).clickCreate(); groupModal.open().fillGroupForm(groups[1]).clickCreate();

View file

@ -165,7 +165,7 @@ describe("Realm settings", () => {
realmSettingsPage.addProvider(); realmSettingsPage.addProvider();
}); });
it("Test keys", function () { it("Test keys", () => {
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
goToKeys(); goToKeys();

View file

@ -115,7 +115,7 @@ describe("User Fed LDAP mapper tests", () => {
it("Create group", () => { it("Create group", () => {
sidebarPage.goToGroups(); sidebarPage.goToGroups();
groupModal groupModal
.open("empty-primary-action") .open("no-groups-in-this-realm-empty-action")
.fillGroupForm(groupName) .fillGroupForm(groupName)
.clickCreate(); .clickCreate();

View file

@ -28,10 +28,8 @@ describe("Group creation", () => {
function createNewGroup() { function createNewGroup() {
groupName += "_" + (Math.random() + 1).toString(36).substring(7); groupName += "_" + (Math.random() + 1).toString(36).substring(7);
groupModal cy.get(".pf-c-spinner__tail-ball").should("not.exist");
.open("openCreateGroupModal") groupModal.open().fillGroupForm(groupName).clickCreate();
.fillGroupForm(groupName)
.clickCreate();
groupsList = [...groupsList, groupName]; groupsList = [...groupsList, groupName];
masthead.checkNotificationMessage("Group created"); masthead.checkNotificationMessage("Group created");

View file

@ -2,7 +2,7 @@ const expect = chai.expect;
export default class RoleMappingTab { export default class RoleMappingTab {
private tab = "#pf-tab-serviceAccount-serviceAccount"; private tab = "#pf-tab-serviceAccount-serviceAccount";
private scopeTab = "scopeTab"; private scopeTab = "scopeTab";
private assignRole = "assignRole"; private assignRole = "no-roles-for-this-client-empty-action";
private unAssign = "unAssignRole"; private unAssign = "unAssignRole";
private assign = "assign"; private assign = "assign";
private hide = "#hideInheritedRoles"; private hide = "#hideInheritedRoles";

View file

@ -9,7 +9,7 @@ export default class AdvancedTab {
private clusterNodesExpand = private clusterNodesExpand =
".pf-c-expandable-section .pf-c-expandable-section__toggle"; ".pf-c-expandable-section .pf-c-expandable-section__toggle";
private testClusterAvailability = "#testClusterAvailability"; private testClusterAvailability = "#testClusterAvailability";
private registerNodeManually = "#registerNodeManually"; private registerNodeManually = "no-nodes-registered-empty-action";
private nodeHost = "#nodeHost"; private nodeHost = "#nodeHost";
private addNodeConfirm = "#add-node-confirm"; private addNodeConfirm = "#add-node-confirm";
@ -65,7 +65,7 @@ export default class AdvancedTab {
} }
clickRegisterNodeManually() { clickRegisterNodeManually() {
cy.get(this.registerNodeManually).click(); cy.getId(this.registerNodeManually).click();
return this; return this;
} }

View file

@ -1,7 +1,7 @@
export default class InitialAccessTokenTab { export default class InitialAccessTokenTab {
private initialAccessTokenTab = "initialAccessToken"; private initialAccessTokenTab = "initialAccessToken";
private emptyAction = "empty-primary-action"; private emptyAction = "no-initial-access-tokens-empty-action";
private expirationInput = "expiration"; private expirationInput = "expiration";
private countInput = "count"; private countInput = "count";

View file

@ -1,11 +1,14 @@
export default class GroupModal { export default class GroupModal {
private openButton = "openCreateGroupModal";
private nameInput = "groupNameInput"; private nameInput = "groupNameInput";
private createButton = "createGroup"; private createButton = "createGroup";
private renameButton = "renameGroup"; private renameButton = "renameGroup";
open(name?: string) { open(name?: string) {
cy.getId(name || this.openButton).click(); if (name) {
cy.getId(name).click();
} else {
cy.get("button").contains("Create").click();
}
return this; return this;
} }

View file

@ -1,5 +1,5 @@
export default class MoveGroupModal { export default class MoveGroupModal {
private moveButton = "joinGroup"; private moveButton = "groups:moveHere-button";
private title = ".pf-c-modal-box__title"; private title = ".pf-c-modal-box__title";
clickRow(groupName: string) { clickRow(groupName: string) {

View file

@ -69,7 +69,7 @@ export default class ProviderPage {
private namesColumn = 'td[data-label="Name"]:visible'; private namesColumn = 'td[data-label="Name"]:visible';
private rolesTab = "#pf-tab-roles-roles"; 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 realmRolesSaveBtn = "data-testid=realm-roles-save-button";
private roleNameField = "#kc-name"; private roleNameField = "#kc-name";
private clientIdSelect = "#kc-client-id"; private clientIdSelect = "#kc-client-id";
@ -217,7 +217,7 @@ export default class ProviderPage {
const ldapAttValue = "cn"; const ldapAttValue = "cn";
const ldapDnValue = "ou=groups"; const ldapDnValue = "ou=groups";
cy.get(`[data-testid="add-mapper-btn"]`).click(); cy.contains("Add").click();
cy.wait(1000); cy.wait(1000);
cy.get("#kc-providerId").click(); cy.get("#kc-providerId").click();

View file

@ -13,11 +13,11 @@ export default class CreateUserPage {
this.usernameInput = "#kc-username"; this.usernameInput = "#kc-username";
this.usersEmptyState = "empty-state"; this.usersEmptyState = "empty-state";
this.emptyStateCreateUserBtn = "empty-primary-action"; this.emptyStateCreateUserBtn = "no-users-found-empty-action";
this.searchPgCreateUserBtn = "create-new-user"; this.searchPgCreateUserBtn = "create-new-user";
this.addUserBtn = "add-user"; this.addUserBtn = "add-user";
this.joinGroupsBtn = "join-groups-button"; this.joinGroupsBtn = "join-groups-button";
this.joinBtn = "join-button"; this.joinBtn = "users:join-button";
this.saveBtn = "create-user"; this.saveBtn = "create-user";
this.cancelBtn = "cancel-create-user"; this.cancelBtn = "cancel-create-user";
} }

View file

@ -6,7 +6,7 @@ export default class UserGroupsPage {
constructor() { constructor() {
this.userGroupsTab = "user-groups-tab"; this.userGroupsTab = "user-groups-tab";
this.addGroupButton = "add-group-button"; this.addGroupButton = "add-group-button";
this.joinGroupButton = "join-button"; this.joinGroupButton = "users:join-button";
} }
goToGroupsTab() { goToGroupsTab() {

View file

@ -37,6 +37,7 @@ import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointCon
import { AuthenticationOverrides } from "./advanced/AuthenticationOverrides"; import { AuthenticationOverrides } from "./advanced/AuthenticationOverrides";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import type { SaveOptions } from "./ClientDetails"; import type { SaveOptions } from "./ClientDetails";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
type AdvancedProps = { type AdvancedProps = {
save: (options?: SaveOptions) => void; 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> </ExpandableSection>
</> </>

View file

@ -167,6 +167,8 @@
"nodeReRegistrationTimeout": "Node Re-registration timeout", "nodeReRegistrationTimeout": "Node Re-registration timeout",
"registeredClusterNodes": "Registered cluster nodes", "registeredClusterNodes": "Registered cluster nodes",
"nodeHost": "Node host", "nodeHost": "Node host",
"noNodes": "No nodes registered",
"noNodesInstructions": "There are no nodes registered, you can add one manually.",
"lastRegistration": "Last registration", "lastRegistration": "Last registration",
"testClusterAvailability": "Test cluster availability", "testClusterAvailability": "Test cluster availability",
"registerNodeManually": "Register node manually", "registerNodeManually": "Register node manually",

View 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>
);
};

View file

@ -53,7 +53,9 @@ export const ListEmptyState = ({
<EmptyStateBody>{instructions}</EmptyStateBody> <EmptyStateBody>{instructions}</EmptyStateBody>
{primaryActionText && ( {primaryActionText && (
<Button <Button
data-testid="empty-primary-action" data-testid={`${message
.replace(/\W+/g, "-")
.toLowerCase()}-empty-action`}
variant="primary" variant="primary"
onClick={onPrimaryAction} onClick={onPrimaryAction}
> >

View file

@ -39,7 +39,7 @@ exports[`<ListEmptyState /> render 1`] = `
data-ouia-component-id="OUIA-Generated-Button-primary-1" data-ouia-component-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button" data-ouia-component-type="PF4/Button"
data-ouia-safe="true" data-ouia-safe="true"
data-testid="empty-primary-action" data-testid="no-things-empty-action"
type="button" type="button"
> >
Add it now! Add it now!

View file

@ -19,6 +19,7 @@ import "./role-mapping.css";
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog";
import { useAdminClient } from "../../context/auth/AdminClient"; import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../alert/Alerts"; import { useAlerts } from "../alert/Alerts";
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
export type CompositeRole = RoleRepresentation & { export type CompositeRole = RoleRepresentation & {
parent: RoleRepresentation; parent: RoleRepresentation;
@ -207,6 +208,14 @@ export const RoleMapping = ({
cellFormatters: [emptyFormatter()], cellFormatters: [emptyFormatter()],
}, },
]} ]}
emptyState={
<ListEmptyState
message={t("noRoles")}
instructions={t("noRolesInstructions")}
primaryActionText={t("assignRole")}
onPrimaryAction={() => setShowAssign(true)}
/>
}
/> />
</> </>
); );

View file

@ -289,12 +289,12 @@ export function KeycloakDataTable<T>({
useFetch( useFetch(
async () => { async () => {
setLoading(true); setLoading(true);
return unPaginatedData || (await loader(first, max, search)); return unPaginatedData || (await loader(first, max + 1, search));
}, },
(data) => { (data) => {
if (!isPaginated) { if (!isPaginated) {
setUnPaginatedData(data); setUnPaginatedData(data);
data = data.slice(first, first + max); data = data.slice(first, first + max + 1);
} }
const result = convertToColumns(data); const result = convertToColumns(data);
@ -360,8 +360,9 @@ export function KeycloakDataTable<T>({
onSelect!(selectedRows); onSelect!(selectedRows);
}; };
const onCollapse = (isOpen: boolean, rowIndex: number) => {
const data = filteredData || rows; const data = filteredData || rows;
const onCollapse = (isOpen: boolean, rowIndex: number) => {
(data![rowIndex] as Row<T>).isOpen = isOpen; (data![rowIndex] as Row<T>).isOpen = isOpen;
setRows([...data!]); setRows([...data!]);
}; };
@ -369,9 +370,9 @@ export function KeycloakDataTable<T>({
return ( return (
<> <>
{!rows && loading && <Loading />} {!rows && loading && <Loading />}
{rows && ( {((data && data.length > 0) || search !== "" || !emptyState) && (
<PaginatingTableToolbar <PaginatingTableToolbar
count={rows.length} count={data?.length || 0}
first={first} first={first}
max={max} max={max}
onNextClick={setFirst} onNextClick={setFirst}
@ -388,7 +389,7 @@ export function KeycloakDataTable<T>({
searchTypeComponent={searchTypeComponent} searchTypeComponent={searchTypeComponent}
toolbarItem={toolbarItem} toolbarItem={toolbarItem}
> >
{!loading && (filteredData || rows).length > 0 && ( {!loading && data && data.length > 0 && (
<DataTable <DataTable
{...props} {...props}
canSelectAll={canSelectAll} canSelectAll={canSelectAll}
@ -396,7 +397,7 @@ export function KeycloakDataTable<T>({
onCollapse={detailColumns ? onCollapse : undefined} onCollapse={detailColumns ? onCollapse : undefined}
actions={convertAction()} actions={convertAction()}
actionResolver={actionResolver} actionResolver={actionResolver}
rows={filteredData || rows} rows={data}
columns={columns} columns={columns}
isNotCompact={isNotCompact} isNotCompact={isNotCompact}
isRadio={isRadio} isRadio={isRadio}
@ -404,8 +405,8 @@ export function KeycloakDataTable<T>({
/> />
)} )}
{!loading && {!loading &&
(filteredData || rows).length === 0 && (!data || data.length === 0) &&
search !== "" && (search !== "" || !emptyState) &&
searchPlaceholderKey && ( searchPlaceholderKey && (
<ListEmptyState <ListEmptyState
hasIcon={true} hasIcon={true}
@ -420,7 +421,7 @@ export function KeycloakDataTable<T>({
)} )}
<> <>
{!loading && {!loading &&
(filteredData || rows)?.length === 0 && (!data || data?.length === 0) &&
search === "" && search === "" &&
emptyState} emptyState}
</> </>

View file

@ -46,10 +46,10 @@ export const PaginatingTableToolbar = ({
isCompact isCompact
toggleTemplate={({ firstIndex, lastIndex }: ToggleTemplateProps) => ( toggleTemplate={({ firstIndex, lastIndex }: ToggleTemplateProps) => (
<b> <b>
{firstIndex} - {lastIndex! - (count < max ? 1 : 0)} {firstIndex} - {lastIndex}
</b> </b>
)} )}
itemCount={count + page * max + (count <= max ? 1 : 0)} itemCount={count + page * max}
page={page + 1} page={page + 1}
perPage={max} perPage={max}
onNextClick={(_, p) => onNextClick((p - 1) * max)} onNextClick={(_, p) => onNextClick((p - 1) * max)}

View file

@ -41,7 +41,9 @@ export function useFetch<T>(
useEffect(() => { useEffect(() => {
adminClientCall() adminClientCall()
.then((result) => { .then((result) => {
if (!source.token.reason) {
callback(result); callback(result);
}
}) })
.catch((error) => { .catch((error) => {
if (!axios.isCancel(error)) { if (!axios.isCancel(error)) {

View file

@ -22,7 +22,7 @@ import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { GroupsModal } from "./GroupsModal"; import { GroupsModal } from "./GroupsModal";
import { getLastId } from "./groupIdUtils"; import { getLastId } from "./groupIdUtils";
import { MoveGroupDialog } from "./MoveGroupDialog"; import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
import { useSubGroups } from "./SubGroupsContext"; import { useSubGroups } from "./SubGroupsContext";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
@ -223,15 +223,23 @@ export const GroupTable = () => {
/> />
)} )}
{move && ( {move && (
<MoveGroupDialog <GroupPickerDialog
group={move} type="selectOne"
filterGroups={[move.name!]}
text={{
title: "groups:moveToGroup",
ok: "groups:moveHere",
}}
onClose={() => setMove(undefined)} onClose={() => setMove(undefined)}
onMove={async (id) => { onConfirm={async (group) => {
delete move.membersLength; delete move.membersLength;
try { try {
try { try {
if (id) { if (group[0].id) {
await adminClient.groups.setOrCreateChild({ id }, move); await adminClient.groups.setOrCreateChild(
{ id: group[0].id },
move
);
} else { } else {
await adminClient.groups.create(move); await adminClient.groups.create(move);
} }

View file

@ -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>
);
};

View file

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@ -21,6 +21,7 @@ import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable
import { useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { useSubGroups } from "./SubGroupsContext";
type SearchGroup = GroupRepresentation & { type SearchGroup = GroupRepresentation & {
link?: string; link?: string;
@ -33,11 +34,13 @@ export const SearchGroups = () => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [searchTerms, setSearchTerms] = useState<string[]>([]); const [searchTerms, setSearchTerms] = useState<string[]>([]);
const [searchCount, setSearchCount] = useState(0);
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime()); const refresh = () => setKey(new Date().getTime());
const { setSubGroups } = useSubGroups();
useEffect(() => setSubGroups([{ name: t("searchGroups") }]), []);
const deleteTerm = (id: string) => { const deleteTerm = (id: string) => {
const index = searchTerms.indexOf(id); const index = searchTerms.indexOf(id);
searchTerms.splice(index, 1); searchTerms.splice(index, 1);
@ -51,13 +54,16 @@ export const SearchGroups = () => {
refresh(); refresh();
}; };
const GroupNameCell = (group: SearchGroup) => ( const GroupNameCell = (group: SearchGroup) => {
setSubGroups([{ name: t("searchGroups"), id: "search" }, group]);
return (
<> <>
<Link key={group.id} to={`/${realm}/groups/${group.link}`}> <Link key={group.id} to={`/${realm}/groups/search/${group.link}`}>
{group.name} {group.name}
</Link> </Link>
</> </>
); );
};
const flatten = ( const flatten = (
groups: GroupRepresentation[], groups: GroupRepresentation[],
@ -92,7 +98,6 @@ export const SearchGroups = () => {
} }
} }
setSearchCount(result.length);
return result; return result;
}; };
@ -136,8 +141,6 @@ export const SearchGroups = () => {
))} ))}
</ChipGroup> </ChipGroup>
</Form> </Form>
</PageSection>
<PageSection variant={searchCount === 0 ? "light" : "default"}>
<KeycloakDataTable <KeycloakDataTable
key={key} key={key}
ariaLabelKey="groups:groups" ariaLabelKey="groups:groups"

View file

@ -13,6 +13,7 @@ import groups from "./groups/messages.json";
import realm from "./realm/messages.json"; import realm from "./realm/messages.json";
import roles from "./realm-roles/messages.json"; import roles from "./realm-roles/messages.json";
import users from "./user/messages.json"; import users from "./user/messages.json";
import usersHelp from "./user/help.json";
import sessions from "./sessions/messages.json"; import sessions from "./sessions/messages.json";
import events from "./events/messages.json"; import events from "./events/messages.json";
import realmSettings from "./realm-settings/messages.json"; import realmSettings from "./realm-settings/messages.json";
@ -40,6 +41,7 @@ const initOptions = {
...roles, ...roles,
...groups, ...groups,
...users, ...users,
...usersHelp,
...sessions, ...sessions,
...userFederation, ...userFederation,
...events, ...events,

View file

@ -273,6 +273,7 @@ export const KeysListTab = ({ realmComponents }: KeysListTabProps) => {
}, },
]} ]}
emptyState={ emptyState={
filterType ? undefined : (
<ListEmptyState <ListEmptyState
hasIcon={true} hasIcon={true}
message={t("realm-settings:noKeys")} message={t("realm-settings:noKeys")}
@ -283,6 +284,7 @@ export const KeysListTab = ({ realmComponents }: KeysListTabProps) => {
primaryActionText={t("createRole")} primaryActionText={t("createRole")}
onPrimaryAction={goToCreate} onPrimaryAction={goToCreate}
/> />
)
} }
/> />
</PageSection> </PageSection>

View file

@ -127,6 +127,7 @@ export const LdapMapperList = () => {
message={t("common:emptyMappers")} message={t("common:emptyMappers")}
instructions={t("common:emptyMappersInstructions")} instructions={t("common:emptyMappersInstructions")}
primaryActionText={t("common:emptyPrimaryAction")} primaryActionText={t("common:emptyPrimaryAction")}
onPrimaryAction={() => history.push(`${url}/new`)}
/> />
} }
/> />

View file

@ -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>
);
};

View file

@ -21,10 +21,10 @@ import { HelpItem } from "../components/help-enabler/HelpItem";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import { useFetch, useAdminClient } from "../context/auth/AdminClient"; import { useFetch, useAdminClient } from "../context/auth/AdminClient";
import moment from "moment"; import moment from "moment";
import { JoinGroupDialog } from "./JoinGroupDialog";
import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation"; import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { emailRegexPattern } from "../util"; import { emailRegexPattern } from "../util";
import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
export type UserFormProps = { export type UserFormProps = {
form: UseFormMethods<UserRepresentation>; form: UseFormMethods<UserRepresentation>;
@ -52,8 +52,7 @@ export const UserForm = ({
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const watchUsernameInput = watch("username"); const watchUsernameInput = watch("username");
const [timestamp, setTimestamp] = useState(null); const [user, setUser] = useState<UserRepresentation>();
const [chips, setChips] = useState<(string | undefined)[]>([]);
const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>( const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>(
[] []
); );
@ -67,19 +66,18 @@ export const UserForm = ({
if (editMode) return await adminClient.users.findOne({ id: id }); if (editMode) return await adminClient.users.findOne({ id: id });
}, },
(user) => { (user) => {
if (user) setupForm(user); if (user) {
setupForm(user);
setUser(user);
}
}, },
[chips] [selectedGroups]
); );
const setupForm = (user: UserRepresentation) => { const setupForm = (user: UserRepresentation) => {
reset(); reset();
Object.entries(user).map((entry) => { 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 deleteItem = (id: string) => {
const copyOfChips = chips; setSelectedGroups(selectedGroups.filter((item) => item.name !== id));
const copyOfGroups = selectedGroups;
setChips(copyOfChips.filter((item) => item !== id));
setSelectedGroups(copyOfGroups.filter((item) => item.name !== id));
onGroupsUpdate(selectedGroups); onGroupsUpdate(selectedGroups);
}; };
const addChips = async (groups: GroupRepresentation[]): Promise<void> => { const addChips = async (groups: GroupRepresentation[]): Promise<void> => {
const newSelectedGroups = groups; setSelectedGroups([...selectedGroups!, ...groups]);
onGroupsUpdate([...selectedGroups!, ...groups]);
const newGroupNames: (string | undefined)[] = newSelectedGroups!.map(
(item) => item.name
);
setChips([...chips!, ...newGroupNames]);
setSelectedGroups([...selectedGroups!, ...newSelectedGroups]);
}; };
onGroupsUpdate(selectedGroups);
const addGroups = async (groups: GroupRepresentation[]): Promise<void> => { const addGroups = async (groups: GroupRepresentation[]): Promise<void> => {
const newGroups = groups; const newGroups = groups;
newGroups.forEach(async (group) => { newGroups.forEach(async (group) => {
try { try {
await adminClient.users.addToGroup({ await adminClient.users.addToGroup({
id: id, id,
groupId: group.id!, groupId: group.id!,
}); });
addAlert(t("users:addedGroupMembership"), AlertVariant.success); addAlert(t("users:addedGroupMembership"), AlertVariant.success);
@ -154,44 +141,34 @@ export const UserForm = ({
className="pf-u-mt-lg" className="pf-u-mt-lg"
> >
{open && ( {open && (
<JoinGroupDialog <GroupPickerDialog
open={open} type="selectMany"
onClose={() => setOpen(!open)} text={{
onConfirm={editMode ? addGroups : addChips} title: "users:selectGroups",
toggleDialog={() => toggleModal()} ok: "users:join",
chips={chips} }}
onConfirm={(groups) => {
editMode ? addGroups(groups) : addChips(groups);
setOpen(false);
}}
onClose={() => setOpen(false)}
filterGroups={selectedGroups.map((g) => g.name!)}
/> />
)} )}
{editMode ? ( {editMode && user ? (
<> <>
<FormGroup <FormGroup label={t("common:id")} fieldId="kc-id" isRequired>
label={t("common:id")} <TextInput id={user.id} value={user.id} type="text" isReadOnly />
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> </FormGroup>
<FormGroup <FormGroup label={t("createdAt")} fieldId="kc-created-at" isRequired>
label={t("createdAt")}
fieldId="kc-created-at"
isRequired
validated={errors.createdTimestamp ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
<TextInput <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" type="text"
id="kc-created-at" id="kc-created-at"
name="createdTimestamp" name="createdTimestamp"
isReadOnly={editMode} isReadOnly
/> />
</FormGroup> </FormGroup>
</> </>
@ -235,7 +212,7 @@ export const UserForm = ({
helperTextInvalid={t("common:required")} helperTextInvalid={t("common:required")}
labelIcon={ labelIcon={
<HelpItem <HelpItem
helpText={t("emailVerifiedHelpText")} helpText="users-help:emailVerified"
forLabel={t("emailVerified")} forLabel={t("emailVerified")}
forID="email-verified" forID="email-verified"
/> />
@ -256,7 +233,7 @@ export const UserForm = ({
labelOff={t("common:off")} labelOff={t("common:off")}
/> />
)} )}
></Controller> />
</FormGroup> </FormGroup>
<FormGroup <FormGroup
label={t("firstName")} label={t("firstName")}
@ -291,7 +268,7 @@ export const UserForm = ({
fieldId="kc-enabled" fieldId="kc-enabled"
labelIcon={ labelIcon={
<HelpItem <HelpItem
helpText={t("disabledHelpText")} helpText="users-help:disabled"
forLabel={t("enabled")} forLabel={t("enabled")}
forID="enabled-label" forID="enabled-label"
/> />
@ -299,20 +276,19 @@ export const UserForm = ({
> >
<Controller <Controller
name="enabled" name="enabled"
defaultValue={false} defaultValue={true}
control={control} control={control}
render={({ onChange, value }) => ( render={({ onChange, value }) => (
<Switch <Switch
data-testid="user-enabled-switch" data-testid="user-enabled-switch"
id={"kc-user-enabled"} id={"kc-user-enabled"}
isDisabled={false}
onChange={(value) => onChange(value)} onChange={(value) => onChange(value)}
isChecked={value} isChecked={value}
label={t("common:on")} label={t("common:on")}
labelOff={t("common:off")} labelOff={t("common:off")}
/> />
)} )}
></Controller> />
</FormGroup> </FormGroup>
<FormGroup <FormGroup
label={t("requiredUserActions")} label={t("requiredUserActions")}
@ -321,7 +297,7 @@ export const UserForm = ({
helperTextInvalid={t("common:required")} helperTextInvalid={t("common:required")}
labelIcon={ labelIcon={
<HelpItem <HelpItem
helpText={t("requiredUserActionsHelpText")} helpText="users-help:requiredUserActions"
forLabel={t("requiredUserActions")} forLabel={t("requiredUserActions")}
forID="required-user-actions-label" forID="required-user-actions-label"
/> />
@ -368,9 +344,9 @@ export const UserForm = ({
helperTextInvalid={t("common:required")} helperTextInvalid={t("common:required")}
labelIcon={ labelIcon={
<HelpItem <HelpItem
helpText={t("requiredUserActionsHelpText")} helpText="users-help:groups"
forLabel={t("requiredUserActions")} forLabel={t("common:groups")}
forID="required-user-actions-label" forID="kc-join-groups-button"
/> />
} }
> >
@ -383,12 +359,12 @@ export const UserForm = ({
<> <>
<InputGroup> <InputGroup>
<ChipGroup categoryName={" "}> <ChipGroup categoryName={" "}>
{chips.map((currentChip) => ( {selectedGroups.map((currentChip) => (
<Chip <Chip
key={currentChip} key={currentChip.id}
onClick={() => deleteItem(currentChip!)} onClick={() => deleteItem(currentChip.name!)}
> >
{currentChip} {currentChip.path}
</Chip> </Chip>
))} ))}
</ChipGroup> </ChipGroup>
@ -414,14 +390,16 @@ export const UserForm = ({
variant="primary" variant="primary"
type="submit" type="submit"
> >
{!editMode ? t("common:Create") : t("common:Save")} {editMode ? t("common:save") : t("common:create")}
</Button> </Button>
<Button <Button
data-testid="cancel-create-user" data-testid="cancel-create-user"
onClick={() => history.push(`/${realm}/users`)} onClick={() =>
editMode ? setupForm(user!) : history.push(`/${realm}/users`)
}
variant="link" variant="link"
> >
{t("common:cancel")} {editMode ? t("common:revert") : t("common:cancel")}
</Button> </Button>
</ActionGroup> </ActionGroup>
</FormAccess> </FormAccess>

View file

@ -19,7 +19,7 @@ import type GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentatio
import { cellWidth } from "@patternfly/react-table"; import { cellWidth } from "@patternfly/react-table";
import _ from "lodash"; import _ from "lodash";
import type UserRepresentation from "keycloak-admin/lib/defs/userRepresentation"; 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 { HelpContext } from "../components/help-enabler/HelpHeader";
import { QuestionCircleIcon } from "@patternfly/react-icons"; import { QuestionCircleIcon } from "@patternfly/react-icons";
@ -38,7 +38,7 @@ export type UserFormProps = {
}; };
export const UserGroups = () => { export const UserGroups = () => {
const { t } = useTranslation("roles"); const { t } = useTranslation("users");
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime()); const refresh = () => setKey(new Date().getTime());
@ -194,34 +194,19 @@ export const UserGroups = () => {
return <>{group.name}</>; return <>{group.name}</>;
}; };
const JoinGroupButtonRenderer = (group: GroupRepresentation) => {
return (
<>
<Button onClick={() => joinGroup(group)} variant="link">
{t("users:joinGroup")}
</Button>
</>
);
};
const toggleModal = () => { const toggleModal = () => {
setOpen(!open); setOpen(!open);
}; };
const joinGroup = (group: GroupRepresentation) => {
setSelectedGroup(group);
toggleModal();
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("users:leaveGroup", { titleKey: t("leaveGroup", {
name: selectedGroup?.name, name: selectedGroup?.name,
}), }),
messageKey: t("users:leaveGroupConfirmDialog", { messageKey: t("leaveGroupConfirmDialog", {
groupname: selectedGroup?.name, groupname: selectedGroup?.name,
username: username, username: username,
}), }),
continueButtonLabel: "users:leave", continueButtonLabel: "leave",
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
try { try {
@ -230,10 +215,10 @@ export const UserGroups = () => {
groupId: selectedGroup!.id!, groupId: selectedGroup!.id!,
}); });
refresh(); refresh();
addAlert(t("users:removedGroupMembership"), AlertVariant.success); addAlert(t("removedGroupMembership"), AlertVariant.success);
} catch (error) { } catch (error) {
addAlert( addAlert(
t("users:removedGroupMembershipError", { error }), t("removedGroupMembershipError", { error }),
AlertVariant.danger AlertVariant.danger
); );
} }
@ -246,25 +231,23 @@ export const UserGroups = () => {
}; };
const LeaveButtonRenderer = (group: GroupRepresentation) => { const LeaveButtonRenderer = (group: GroupRepresentation) => {
if ( const canLeaveGroup =
directMembershipList.some((item) => item.id === group.id) || directMembershipList.some((item) => item.id === group.id) ||
directMembershipList.length === 0 || directMembershipList.length === 0 ||
isDirectMembership isDirectMembership;
) {
return ( return (
<> <>
{canLeaveGroup && (
<Button <Button
data-testid={`leave-${group.name}`} data-testid={`leave-${group.name}`}
onClick={() => leave(group)} onClick={() => leave(group)}
variant="link" variant="link"
> >
{t("users:Leave")} {t("leave")}
</Button> </Button>
)}
</> </>
); );
} else {
return <> </>;
}
}; };
const addGroups = async (groups: GroupRepresentation[]): Promise<void> => { const addGroups = async (groups: GroupRepresentation[]): Promise<void> => {
@ -278,10 +261,10 @@ export const UserGroups = () => {
}); });
setList(true); setList(true);
refresh(); refresh();
addAlert(t("users:addedGroupMembership"), AlertVariant.success); addAlert(t("addedGroupMembership"), AlertVariant.success);
} catch (error) { } catch (error) {
addAlert( addAlert(
t("users:addedGroupMembershipError", { error }), t("addedGroupMembershipError", { error }),
AlertVariant.danger AlertVariant.danger
); );
} }
@ -293,12 +276,18 @@ export const UserGroups = () => {
<PageSection variant="light"> <PageSection variant="light">
<DeleteConfirm /> <DeleteConfirm />
{open && ( {open && (
<JoinGroupDialog <GroupPickerDialog
open={open} id={id}
onClose={() => setOpen(!open)} type="selectMany"
onConfirm={addGroups} text={{
toggleDialog={() => toggleModal()} title: t("joinGroupsFor", { username }),
username={username} ok: "users:join",
}}
onClose={() => setOpen(false)}
onConfirm={(groups) => {
addGroups(groups);
setOpen(false);
}}
/> />
)} )}
<KeycloakDataTable <KeycloakDataTable
@ -317,11 +306,10 @@ export const UserGroups = () => {
onClick={toggleModal} onClick={toggleModal}
data-testid="add-group-button" data-testid="add-group-button"
> >
{t("users:joinGroup")} {t("joinGroup")}
</Button> </Button>
{JoinGroupButtonRenderer}
<Checkbox <Checkbox
label={t("users:directMembership")} label={t("directMembership")}
key="direct-membership-check" key="direct-membership-check"
id="kc-direct-membership-checkbox" id="kc-direct-membership-checkbox"
onChange={() => setDirectMembership(!isDirectMembership)} onChange={() => setDirectMembership(!isDirectMembership)}
@ -332,7 +320,7 @@ export const UserGroups = () => {
<Popover <Popover
aria-label="Basic popover" aria-label="Basic popover"
position="bottom" position="bottom"
bodyContent={<div>{t("users:whoWillAppearPopoverText")}</div>} bodyContent={<div>{t("whoWillAppearPopoverText")}</div>}
> >
<Button <Button
variant="link" variant="link"
@ -340,7 +328,7 @@ export const UserGroups = () => {
key="who-will-appear-button" key="who-will-appear-button"
icon={<QuestionCircleIcon />} icon={<QuestionCircleIcon />}
> >
{t("users:whoWillAppearLinkText")} {t("whoWillAppearLinkText")}
</Button> </Button>
</Popover> </Popover>
)} )}
@ -356,7 +344,7 @@ export const UserGroups = () => {
}, },
{ {
name: "path", name: "path",
displayKey: "users:Path", displayKey: "users:path",
cellFormatters: [emptyFormatter()], cellFormatters: [emptyFormatter()],
transforms: [cellWidth(45)], transforms: [cellWidth(45)],
}, },
@ -372,8 +360,8 @@ export const UserGroups = () => {
!search ? ( !search ? (
<ListEmptyState <ListEmptyState
hasIcon={true} hasIcon={true}
message={t("users:noGroups")} message={t("noGroups")}
instructions={t("users:noGroupsText")} instructions={t("noGroupsText")}
/> />
) : ( ) : (
"" ""

View file

@ -49,7 +49,7 @@ export const UsersTabs = () => {
const save = async (user: UserRepresentation) => { const save = async (user: UserRepresentation) => {
try { try {
if (id) { if (id) {
await adminClient.users.update({ id: user.id! }, user); await adminClient.users.update({ id }, user);
addAlert(t("users:userSaved"), AlertVariant.success); addAlert(t("users:userSaved"), AlertVariant.success);
} else { } else {
const createdUser = await adminClient.users.create(user); const createdUser = await adminClient.users.create(user);

9
src/user/help.json Normal file
View 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."
}
}

View file

@ -11,7 +11,8 @@
"noGroupsText": "You haven't added this user to any groups. Join a group to get started.", "noGroupsText": "You haven't added this user to any groups. Join a group to get started.",
"joinGroup": "Join Group", "joinGroup": "Join Group",
"joinGroups": "Join Groups", "joinGroups": "Join Groups",
"joinGroupsFor": "Join groups for user ", "join": "Join",
"joinGroupsFor": "Join groups for user {{username}}",
"selectGroups": "Select groups to join", "selectGroups": "Select groups to join",
"searchForGroups": "Search for groups", "searchForGroups": "Search for groups",
"leave": "Leave", "leave": "Leave",
@ -33,13 +34,10 @@
"firstName": "First name", "firstName": "First name",
"status": "Status", "status": "Status",
"disabled": "Disabled", "disabled": "Disabled",
"disabledHelpText": "A disabled user cannot log in.",
"emailVerifiedHelpText": "Has the user's email been verified?",
"emailInvalid": "You must enter a valid email.", "emailInvalid": "You must enter a valid email.",
"temporaryDisabled": "Temporarily disabled", "temporaryDisabled": "Temporarily disabled",
"notVerified": "Not verified", "notVerified": "Not verified",
"requiredUserActions": "Required user actions", "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", "addUser": "Add user",
"deleteUser": "Delete user", "deleteUser": "Delete user",
"deleteConfirm": "Delete user?", "deleteConfirm": "Delete user?",