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", () => {
advancedTab.expandClusterNode().checkTestClusterAvailability(false);
advancedTab.expandClusterNode();
advancedTab
.clickRegisterNodeManually()

View file

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

View file

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

View file

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

View file

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

View file

@ -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";

View file

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

View file

@ -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";

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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() {

View file

@ -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>
</>

View file

@ -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",

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>
{primaryActionText && (
<Button
data-testid="empty-primary-action"
data-testid={`${message
.replace(/\W+/g, "-")
.toLowerCase()}-empty-action`}
variant="primary"
onClick={onPrimaryAction}
>

View file

@ -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!

View file

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

View file

@ -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}
</>

View file

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

View file

@ -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)) {

View file

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

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 { 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"

View file

@ -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,

View file

@ -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>

View file

@ -127,6 +127,7 @@ export const LdapMapperList = () => {
message={t("common:emptyMappers")}
instructions={t("common:emptyMappersInstructions")}
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 { 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>

View file

@ -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")}
/>
) : (
""

View file

@ -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
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.",
"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?",