Merge pull request #410 from jenny-s51/createUser

Users: Adds create user page to users section
This commit is contained in:
mfrances17 2021-03-04 15:09:53 -05:00 committed by GitHub
commit 7c816736d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 456 additions and 8 deletions

View file

@ -0,0 +1,50 @@
import SidebarPage from "../support/pages/admin_console/SidebarPage";
import LoginPage from "../support/pages/LoginPage";
import CreateUserPage from "../support/pages/admin_console/manage/users/CreateUserPage";
import Masthead from "../support/pages/admin_console/Masthead";
import ListingPage from "../support/pages/admin_console/ListingPage";
describe("Users test", () => {
const loginPage = new LoginPage();
const sidebarPage = new SidebarPage();
const createUserPage = new CreateUserPage();
const masthead = new Masthead();
const listingPage = new ListingPage();
let itemId = "user_crud";
describe("User creation", () => {
beforeEach(function () {
cy.visit("");
loginPage.logIn();
sidebarPage.goToUsers();
});
it("Go to create User page", () => {
cy.wait(100);
createUserPage.goToCreateUser();
cy.url().should("include", "users/add-user");
// Verify Cancel button works
createUserPage.cancel();
cy.url().should("not.include", "/add-user");
});
it("Create user test", function () {
itemId += "_" + (Math.random() + 1).toString(36).substring(7);
// Create
cy.wait(100);
createUserPage.goToCreateUser();
createUserPage.fillRealmRoleData(itemId).save();
masthead.checkNotificationMessage("The user has been created");
sidebarPage.goToUsers();
listingPage.searchItem(itemId).itemExist(itemId);
});
});
});

View file

@ -0,0 +1,56 @@
export default class CreateUserPage {
usernameInput: string;
usersEmptyState: string;
emptyStateCreateUserBtn: string;
searchPgCreateUserBtn: string;
saveBtn: string;
cancelBtn: string;
constructor() {
this.usernameInput = "#kc-username";
this.usersEmptyState = "[data-testid=empty-state]";
this.emptyStateCreateUserBtn = "[data-testid=empty-primary-action]";
this.searchPgCreateUserBtn = "[data-testid=create-new-user]";
this.saveBtn = "[data-testid=create-user]";
this.cancelBtn = "[data-testid=cancel-create-user]";
}
//#region General Settings
fillRealmRoleData(username: string) {
cy.get(this.usernameInput).clear();
if (username) {
cy.get(this.usernameInput).type(username);
}
return this;
}
goToCreateUser() {
cy.wait(100);
cy.get("body").then((body) => {
if (body.find(this.usersEmptyState).length > 0) {
cy.get(this.emptyStateCreateUserBtn).click();
} else if (body.find("[data-testid=search-users-title]").length > 0) {
cy.get(this.searchPgCreateUserBtn).click();
} else {
cy.get("[data-testid=add-user]").click();
}
});
return this;
}
save() {
cy.get(this.saveBtn).click();
return this;
}
cancel() {
cy.get(this.cancelBtn).click();
return this;
}
}

View file

@ -38,7 +38,7 @@ export const ListEmptyState = ({
}: ListEmptyStateProps) => { }: ListEmptyStateProps) => {
return ( return (
<> <>
<EmptyState variant="large"> <EmptyState data-testid="empty-state" variant="large">
{hasIcon && isSearchVariant ? ( {hasIcon && isSearchVariant ? (
<EmptyStateIcon icon={SearchIcon} /> <EmptyStateIcon icon={SearchIcon} />
) : ( ) : (

View file

@ -4,6 +4,7 @@ exports[`<ListEmptyState /> render 1`] = `
<DocumentFragment> <DocumentFragment>
<div <div
class="pf-c-empty-state pf-m-lg" class="pf-c-empty-state pf-m-lg"
data-testid="empty-state"
> >
<div <div
class="pf-c-empty-state__content" class="pf-c-empty-state__content"

View file

@ -27,6 +27,7 @@ export type ViewHeaderProps = {
badge?: string; badge?: string;
badgeId?: string; badgeId?: string;
badgeIsRead?: boolean; badgeIsRead?: boolean;
dividerComponent?: "div" | "hr" | "li" | undefined;
subKey: string; subKey: string;
actionsDropdownId?: string; actionsDropdownId?: string;
subKeyLinkProps?: FormattedLinkProps; subKeyLinkProps?: FormattedLinkProps;
@ -42,6 +43,7 @@ export const ViewHeader = ({
titleKey, titleKey,
badge, badge,
badgeIsRead, badgeIsRead,
dividerComponent,
subKey, subKey,
subKeyLinkProps, subKeyLinkProps,
dropdownItems, dropdownItems,
@ -159,7 +161,7 @@ export const ViewHeader = ({
/> />
)} )}
</PageSection> </PageSection>
<Divider /> <Divider component={dividerComponent} />
</> </>
); );
}; };

View file

@ -21,6 +21,7 @@ import { UserFederationSection } from "./user-federation/UserFederationSection";
import { UsersSection } from "./user/UsersSection"; import { UsersSection } from "./user/UsersSection";
import { MappingDetails } from "./client-scopes/details/MappingDetails"; import { MappingDetails } from "./client-scopes/details/MappingDetails";
import { ClientDetails } from "./clients/ClientDetails"; import { ClientDetails } from "./clients/ClientDetails";
import { UsersTabs } from "./user/UsersTabs";
import { UserFederationKerberosSettings } from "./user-federation/UserFederationKerberosSettings"; import { UserFederationKerberosSettings } from "./user-federation/UserFederationKerberosSettings";
import { UserFederationLdapSettings } from "./user-federation/UserFederationLdapSettings"; import { UserFederationLdapSettings } from "./user-federation/UserFederationLdapSettings";
import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm"; import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm";
@ -143,6 +144,12 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("users:title"), breadcrumb: t("users:title"),
access: "query-users", access: "query-users",
}, },
{
path: "/:realm/users/add-user",
component: UsersTabs,
breadcrumb: t("users:createUser"),
access: "manage-users",
},
{ {
path: "/:realm/sessions", path: "/:realm/sessions",
component: SessionsSection, component: SessionsSection,

View file

@ -12,6 +12,7 @@ import {
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { SearchIcon } from "@patternfly/react-icons"; import { SearchIcon } from "@patternfly/react-icons";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useHistory, useRouteMatch } from "react-router-dom";
type SearchUserProps = { type SearchUserProps = {
onSearch: (search: string) => void; onSearch: (search: string) => void;
@ -20,9 +21,14 @@ type SearchUserProps = {
export const SearchUser = ({ onSearch }: SearchUserProps) => { export const SearchUser = ({ onSearch }: SearchUserProps) => {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const { register, handleSubmit } = useForm<{ search: string }>(); const { register, handleSubmit } = useForm<{ search: string }>();
const { url } = useRouteMatch();
const history = useHistory();
const goToCreate = () => history.push(`${url}/add-user`);
return ( return (
<EmptyState> <EmptyState>
<Title headingLevel="h4" size="lg"> <Title data-testid="search-users-title" headingLevel="h4" size="lg">
{t("startBySearchingAUser")} {t("startBySearchingAUser")}
</Title> </Title>
<EmptyStateBody> <EmptyStateBody>
@ -44,7 +50,7 @@ export const SearchUser = ({ onSearch }: SearchUserProps) => {
</InputGroup> </InputGroup>
</Form> </Form>
</EmptyStateBody> </EmptyStateBody>
<Button variant="link" onClick={() => {}}> <Button data-testid="create-new-user" variant="link" onClick={goToCreate}>
{t("createNewUser")} {t("createNewUser")}
</Button> </Button>
</EmptyState> </EmptyState>

247
src/user/UserForm.tsx Normal file
View file

@ -0,0 +1,247 @@
import React, { useState } from "react";
import {
ActionGroup,
Button,
FormGroup,
Select,
SelectOption,
Switch,
TextInput,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { Controller, UseFormMethods } from "react-hook-form";
import { useHistory } from "react-router-dom";
import { FormAccess } from "../components/form-access/FormAccess";
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import { HelpItem } from "../components/help-enabler/HelpItem";
import { useRealm } from "../context/realm-context/RealmContext";
export type UserFormProps = {
form: UseFormMethods<UserRepresentation>;
save: (user: UserRepresentation) => void;
};
export const UserForm = ({
form: { handleSubmit, register, errors, watch, control },
save,
}: UserFormProps) => {
const { t } = useTranslation("users");
const { realm } = useRealm();
const [
isRequiredUserActionsDropdownOpen,
setRequiredUserActionsDropdownOpen,
] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const history = useHistory();
const watchUsernameInput = watch("username");
const emailRegexPattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const requiredUserActionsOptions = [
<SelectOption key={0} value="Configure OTP">
{t("configureOTP")}
</SelectOption>,
<SelectOption key={1} value="Update Password">
{t("updatePassword")}
</SelectOption>,
<SelectOption key={2} value="Update Profile">
{t("updateProfile")}
</SelectOption>,
<SelectOption key={3} value="Verify Email">
{t("verifyEmail")}
</SelectOption>,
<SelectOption key={4} value="Update User Locale">
{t("updateUserLocale")}
</SelectOption>,
];
const clearSelection = () => {
setSelected([]);
setRequiredUserActionsDropdownOpen(false);
};
return (
<FormAccess
isHorizontal
onSubmit={handleSubmit(save)}
role="manage-users"
className="pf-u-mt-lg"
>
<FormGroup
label={t("username")}
fieldId="kc-username"
isRequired
validated={errors.username ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-username"
name="username"
/>
</FormGroup>
<FormGroup
label={t("email")}
fieldId="kc-description"
validated={errors.email ? "error" : "default"}
helperTextInvalid={t("users:emailInvalid")}
>
<TextInput
ref={register({
pattern: emailRegexPattern,
})}
type="email"
id="kc-email"
name="email"
aria-label={t("emailInput")}
/>
</FormGroup>
<FormGroup
label={t("emailVerified")}
fieldId="kc-email-verified"
helperTextInvalid={t("common:required")}
labelIcon={
<HelpItem
helpText={t("emailVerifiedHelpText")}
forLabel={t("emailVerified")}
forID="email-verified"
/>
}
>
<Controller
name="user-email-verified"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id={"kc-user-email-verified"}
isDisabled={false}
onChange={(value) => onChange([`${value}`])}
isChecked={value[0] === "true"}
label={t("common:on")}
labelOff={t("common:off")}
/>
)}
></Controller>
</FormGroup>
<FormGroup
label={t("firstName")}
fieldId="kc-firstname"
validated={errors.firstName ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register()}
type="text"
id="kc-firstname"
name="firstName"
/>
</FormGroup>
<FormGroup
label={t("lastName")}
fieldId="kc-name"
validated={errors.lastName ? "error" : "default"}
>
<TextInput
ref={register()}
type="text"
id="kc-lastname"
name="lastName"
aria-label={t("lastName")}
/>
</FormGroup>
<FormGroup
label={t("common:enabled")}
fieldId="kc-enabled"
labelIcon={
<HelpItem
helpText={t("disabledHelpText")}
forLabel={t("enabled")}
forID="enabled-label"
/>
}
>
<Controller
name="user-enabled"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id={"kc-user-enabled"}
isDisabled={false}
onChange={(value) => onChange([`${value}`])}
isChecked={value[0] === "true"}
label={t("common:on")}
labelOff={t("common:off")}
/>
)}
></Controller>
</FormGroup>
<FormGroup
label={t("requiredUserActions")}
fieldId="kc-required-user-actions"
validated={errors.requiredActions ? "error" : "default"}
helperTextInvalid={t("common:required")}
labelIcon={
<HelpItem
helpText={t("requiredUserActionsHelpText")}
forLabel={t("requiredUserActions")}
forID="required-user-actions-label"
/>
}
>
<Controller
name="required-user-actions"
defaultValue={["0"]}
typeAheadAriaLabel="Select an action"
control={control}
render={() => (
<Select
placeholderText="Select action"
toggleId="kc-required-user-actions"
onToggle={() =>
setRequiredUserActionsDropdownOpen(
!isRequiredUserActionsDropdownOpen
)
}
isOpen={isRequiredUserActionsDropdownOpen}
selections={selected}
onSelect={(_, value) => {
const option = value as string;
if (selected.includes(option)) {
setSelected(selected.filter((item) => item !== option));
} else {
setSelected([...selected, option]);
}
}}
onClear={clearSelection}
variant="typeaheadmulti"
>
{requiredUserActionsOptions}
</Select>
)}
></Controller>
</FormGroup>
<ActionGroup>
<Button
data-testid="create-user"
isDisabled={!watchUsernameInput}
variant="primary"
type="submit"
>
{t("common:Create")}
</Button>
<Button
data-testid="cancel-create-user"
onClick={() => history.push(`/${realm}/users`)}
variant="link"
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
);
};

View file

@ -28,6 +28,7 @@ import { emptyFormatter } from "../util";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import "./user-section.css"; import "./user-section.css";
import { useHistory, useRouteMatch } from "react-router-dom";
type BruteUser = UserRepresentation & { type BruteUser = UserRepresentation & {
brute?: Record<string, object>; brute?: Record<string, object>;
@ -39,6 +40,8 @@ export const UsersSection = () => {
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const { realm: realmName } = useContext(RealmContext); const { realm: realmName } = useContext(RealmContext);
const history = useHistory();
const { url } = useRouteMatch();
const [listUsers, setListUsers] = useState(false); const [listUsers, setListUsers] = useState(false);
const [initialSearch, setInitialSearch] = useState(""); const [initialSearch, setInitialSearch] = useState("");
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]); const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
@ -157,11 +160,13 @@ export const UsersSection = () => {
); );
}; };
const goToCreate = () => history.push(`${url}/add-user`);
return ( return (
<> <>
<DeleteConfirm /> <DeleteConfirm />
<ViewHeader titleKey="users:title" subKey="" /> <ViewHeader titleKey="users:title" subKey="" />
<PageSection variant="light"> <PageSection data-testid="users-page" variant="light">
{!listUsers && !initialSearch && ( {!listUsers && !initialSearch && (
<SearchUser <SearchUser
onSearch={(search) => { onSearch={(search) => {
@ -183,13 +188,15 @@ export const UsersSection = () => {
message={t("noUsersFound")} message={t("noUsersFound")}
instructions={t("emptyInstructions")} instructions={t("emptyInstructions")}
primaryActionText={t("createNewUser")} primaryActionText={t("createNewUser")}
onPrimaryAction={() => {}} onPrimaryAction={goToCreate}
/> />
} }
toolbarItem={ toolbarItem={
<> <>
<ToolbarItem> <ToolbarItem>
<Button>{t("addUser")}</Button> <Button data-testid="add-user" onClick={goToCreate}>
{t("addUser")}
</Button>
</ToolbarItem> </ToolbarItem>
<ToolbarItem> <ToolbarItem>
<Button <Button

57
src/user/UsersTabs.tsx Normal file
View file

@ -0,0 +1,57 @@
import React from "react";
import { AlertVariant, PageSection } from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { ViewHeader } from "../components/view-header/ViewHeader";
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import { UserForm } from "./UserForm";
import { useAlerts } from "../components/alert/Alerts";
import { useAdminClient } from "../context/auth/AdminClient";
import { useHistory, useRouteMatch } from "react-router-dom";
export const UsersTabs = () => {
const { t } = useTranslation("roles");
const { addAlert } = useAlerts();
const { url } = useRouteMatch();
const history = useHistory();
const adminClient = useAdminClient();
const form = useForm<UserRepresentation>({ mode: "onChange" });
const save = async (user: UserRepresentation) => {
try {
await adminClient.users.create({
username: user!.username,
email: user!.email,
emailVerified: user!.emailVerified,
firstName: user!.firstName,
lastName: user!.lastName,
enabled: user!.enabled,
requiredActions: user!.requiredActions,
});
addAlert(t("users:userCreated"), AlertVariant.success);
history.push(url.substr(0, url.lastIndexOf("/")));
} catch (error) {
addAlert(
t("users:userCreateError", {
error: error.response.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
return (
<>
<ViewHeader
titleKey={t("users:createUser")}
subKey=""
dividerComponent="div"
/>
<PageSection variant="light">
<UserForm form={form} save={save} />
</PageSection>
</>
);
};

View file

@ -3,24 +3,39 @@
"title": "Users", "title": "Users",
"searchForUser": "Search user", "searchForUser": "Search user",
"startBySearchingAUser": "Start by searching for users", "startBySearchingAUser": "Start by searching for users",
"createUser": "Create user",
"createNewUser": "Create new user", "createNewUser": "Create new user",
"noUsersFound": "No users found", "noUsersFound": "No users found",
"noUsersFoundError": "No users found due to {{error}}", "noUsersFoundError": "No users found due to {{error}}",
"emptyInstructions": "Change your search criteria or add a user", "emptyInstructions": "Change your search criteria or add a user",
"username": "Username", "username": "Username",
"email": "Email", "email": "Email",
"emailVerified": "Email verified",
"lastName": "Last name", "lastName": "Last name",
"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.",
"temporaryDisabled": "Temporarily disabled", "temporaryDisabled": "Temporarily disabled",
"notVerified": "Not verified", "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", "addUser": "Add user",
"deleteUser": "Delete user", "deleteUser": "Delete user",
"deleteConfirm": "Delete user?", "deleteConfirm": "Delete user?",
"deleteConfirmDialog": "Are you sure you want to permanently delete {{count}} selected user", "deleteConfirmDialog": "Are you sure you want to permanently delete {{count}} selected user",
"deleteConfirmDialog_plural": "Are you sure you want to permanently delete {{count}} selected users", "deleteConfirmDialog_plural": "Are you sure you want to permanently delete {{count}} selected users",
"userCreated": "The user has been created",
"userCreateError": "Could not create user: {{error}}",
"userDeletedSuccess": "The user has been deleted", "userDeletedSuccess": "The user has been deleted",
"userDeletedError": "The user could not be deleted {{error}}" "userDeletedError": "The user could not be deleted {{error}}",
"configureOTP": "Configure OTP",
"updatePassword": "Update Password",
"updateProfile": "Update Profile",
"verifyEmail": "Verify Email",
"updateUserLocale": "Update User Locale"
} }
} }