Refactored user credentials into seperate components (#2024)

This commit is contained in:
Erik Jan de Wit 2022-02-09 15:39:10 +01:00 committed by GitHub
parent 2030c55e48
commit 8de96be98b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 797 additions and 913 deletions

View file

@ -197,8 +197,9 @@ describe("User creation", () => {
.goToCredentialsTab()
.clickEmptyStateResetBtn()
.fillResetCredentialForm();
masthead.checkNotificationMessage("Failed to send email to user.");
modalUtils.cancelModal();
masthead.checkNotificationMessage(
"Failed: Failed to send execute actions email"
);
});
it("Reset credential of User with existing credentials", () => {
@ -208,8 +209,9 @@ describe("User creation", () => {
.clickResetBtn()
.fillResetCredentialForm();
masthead.checkNotificationMessage("Failed to send email to user.");
modalUtils.cancelModal();
masthead.checkNotificationMessage(
"Failed: Failed to send execute actions email"
);
});
it("Delete user test", () => {

View file

@ -3,7 +3,7 @@ export default class CredentialsPage {
private readonly emptyStatePasswordBtn = "no-credentials-empty-action";
private readonly emptyStateResetBtn = "credential-reset-empty-action";
private readonly resetBtn = "credentialResetBtn";
private readonly setPasswordBtn = "setPasswordBtn";
private readonly setPasswordBtn = "confirm";
private readonly credentialResetModal = "credential-reset-modal";
private readonly resetModalActionsToggleBtn =
"[data-testid=credential-reset-modal] #actions";
@ -16,7 +16,7 @@ export default class CredentialsPage {
"UPDATE_PASSWORD-option",
"terms_and_conditions-option",
];
private readonly confirmationButton = "okBtn";
private readonly confirmationButton = "confirm";
goToCredentialsTab() {
cy.findByTestId(this.credentialsTab).click();

File diff suppressed because it is too large Load diff

View file

@ -126,19 +126,17 @@ export default {
cancel: "Cancel",
savePasswordSuccess: "The password has been set successfully.",
savePasswordError: "Error saving password: {{error}}",
savePasswordNotMatchError:
"Error saving password: Password and confirmation does not match.",
confirmPasswordDoesNotMatch: "Password and confirmation does not match.",
credentialType: "Type",
credentialUserLabel: "User Label",
credentialData: "Data",
credentialsList: "Credentials List",
setPasswordConfirm: "Set password?",
setPasswordConfirmText:
"Are you sure you want to set the password for the user",
"Are you sure you want to set the password for the user {{username}}?",
password: "Password",
passwordConfirmation: "Password confirmation",
resetPasswordConfirmation: "New password confirmation",
questionMark: "?",
savePassword: "Save password",
deleteCredentialsConfirmTitle: "Delete credentials?",
deleteCredentialsConfirm:
@ -149,12 +147,10 @@ export default {
resetPasswordFor: "Reset password for {{username}}",
resetPasswordConfirm: "Reset password?",
resetPasswordConfirmText:
"Are you sure you want to reset the password for the user",
"Are you sure you want to reset the password for the user {{username}}?",
resetPassword: "Reset password",
resetCredentialsSuccess: "The password has been reset successfully.",
resetCredentialsError: "Error resetting users credentials: {{error}}",
resetPasswordNotMatchError:
"Error resetting password: Password and confirmation does not match.",
resetPasswordError: "Error resetting password: {{error}}",
resetPasswordBtn: "Reset password",
showPasswordDataName: "Name",
@ -185,6 +181,6 @@ export default {
credentialResetConfirm: "Send Email",
credentialResetConfirmText: "Are you sure you want to send email to user",
credentialResetEmailSuccess: "Email sent to user.",
credentialResetEmailError: "Failed to send email to user.",
credentialResetEmailError: "Failed: {{error}}",
},
};

View file

@ -0,0 +1,40 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalVariant } from "@patternfly/react-core";
import {
Table,
TableBody,
TableHeader,
TableVariant,
} from "@patternfly/react-table";
type CredentialDataDialogProps = {
credentialData: [string, string][];
onClose: () => void;
};
export const CredentialDataDialog = ({
credentialData,
onClose,
}: CredentialDataDialogProps) => {
const { t } = useTranslation("users");
return (
<Modal
variant={ModalVariant.medium}
title={t("passwordDataTitle")}
isOpen
onClose={onClose}
>
<Table
aria-label={t("passwordDataTitle")}
data-testid="password-data-dialog"
variant={TableVariant.compact}
cells={[t("showPasswordDataName"), t("showPasswordDataValue")]}
rows={credentialData}
>
<TableHeader />
<TableBody />
</Table>
</Modal>
);
};

View file

@ -0,0 +1,115 @@
import React, { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Td } from "@patternfly/react-table";
import {
Button,
Dropdown,
DropdownPosition,
KebabToggle,
DropdownItem,
} from "@patternfly/react-core";
import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation";
import { useWhoAmI } from "../../context/whoami/WhoAmI";
import useToggle from "../../utils/useToggle";
import { CredentialDataDialog } from "./CredentialDataDialog";
type CredentialRowProps = {
credential: CredentialRepresentation;
resetPassword: () => void;
toggleDelete: () => void;
children: ReactNode;
};
export const CredentialRow = ({
credential,
resetPassword,
toggleDelete,
children,
}: CredentialRowProps) => {
const { t } = useTranslation("users");
const [showData, toggleShow] = useToggle();
const [kebabOpen, toggleKebab] = useToggle();
const { whoAmI } = useWhoAmI();
const rows = useMemo(() => {
if (!credential.credentialData) {
return [];
}
const credentialData: Record<string, unknown> = JSON.parse(
credential.credentialData
);
const locale = whoAmI.getLocale();
return Object.entries(credentialData)
.sort(([a], [b]) => a.localeCompare(b, locale))
.map<[string, string]>(([key, value]) => {
if (typeof value === "string") {
return [key, value];
}
return [key, JSON.stringify(value)];
});
}, [credential.credentialData]);
return (
<>
{showData && Object.keys(credential).length !== 0 && (
<CredentialDataDialog
credentialData={rows}
onClose={() => {
toggleShow();
}}
/>
)}
<Td>{children}</Td>
<Td>
<Button
className="kc-showData-btn"
variant="link"
data-testid="showDataBtn"
onClick={toggleShow}
>
{t("showDataBtn")}
</Button>
</Td>
{credential.type === "password" ? (
<Td>
<Button
variant="secondary"
data-testid="resetPasswordBtn"
onClick={resetPassword}
>
{t("resetPasswordBtn")}
</Button>
</Td>
) : (
<Td />
)}
<Td>
<Dropdown
isPlain
position={DropdownPosition.right}
toggle={<KebabToggle onToggle={toggleKebab} />}
isOpen={kebabOpen}
dropdownItems={[
<DropdownItem
key={credential.id}
data-testid="deleteDropdownItem"
component="button"
onClick={() => {
toggleDelete();
toggleKebab();
}}
>
{t("deleteBtn")}
</DropdownItem>,
]}
/>
</Td>
</>
);
};

View file

@ -0,0 +1,72 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { RequiredActionAlias } from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation";
import { HelpItem } from "../../components/help-enabler/HelpItem";
export const CredentialsResetActionMultiSelect = () => {
const { t } = useTranslation("users");
const { control } = useFormContext();
const [open, setOpen] = useState(false);
return (
<FormGroup
label={t("resetActions")}
labelIcon={
<HelpItem
helpText="clients-help:resetActions"
fieldLabelId="resetActions"
/>
}
fieldId="actions"
>
<Controller
name="actions"
defaultValue={[]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="actions"
variant={SelectVariant.typeaheadMulti}
chipGroupProps={{
numChips: 3,
}}
menuAppendTo="parent"
onToggle={(open) => setOpen(open)}
isOpen={open}
selections={value}
onSelect={(_, selectedValue) =>
onChange(
value.find((o: string) => o === selectedValue)
? value.filter((item: string) => item !== selectedValue)
: [...value, selectedValue]
)
}
onClear={(event) => {
event.stopPropagation();
onChange([]);
}}
aria-label={t("resetActions")}
>
{Object.values(RequiredActionAlias).map((action, index) => (
<SelectOption
key={index}
value={action}
data-testid={`${action}-option`}
>
{t(action)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
);
};

View file

@ -0,0 +1,106 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import {
AlertVariant,
Button,
Form,
FormGroup,
TextInput,
} from "@patternfly/react-core";
import { CheckIcon, PencilAltIcon, TimesIcon } from "@patternfly/react-icons";
import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
type UserLabelForm = {
userLabel: string;
};
type InlineLabelEditProps = {
userId: string;
credential: CredentialRepresentation;
isEditable: boolean;
toggle: () => void;
};
export const InlineLabelEdit = ({
userId,
credential,
isEditable,
toggle,
}: InlineLabelEditProps) => {
const { t } = useTranslation("users");
const { register, handleSubmit } = useForm<UserLabelForm>();
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const saveUserLabel = async (userLabel: UserLabelForm) => {
try {
await adminClient.users.updateCredentialLabel(
{
id: userId,
credentialId: credential.id!,
},
userLabel.userLabel || ""
);
addAlert(t("updateCredentialUserLabelSuccess"), AlertVariant.success);
toggle();
} catch (error) {
addError("users:updateCredentialUserLabelError", error);
}
};
return (
<Form isHorizontal className="kc-form-userLabel">
<FormGroup fieldId="kc-userLabel" className="kc-userLabel-row">
<div className="kc-form-group-userLabel">
{isEditable ? (
<>
<TextInput
name="userLabel"
defaultValue={credential.userLabel}
ref={register()}
type="text"
className="kc-userLabel"
aria-label={t("userLabel")}
data-testid="user-label-fld"
/>
<div className="kc-userLabel-actionBtns">
<Button
data-testid="editUserLabel-acceptBtn"
variant="link"
className="kc-editUserLabel-acceptBtn"
onClick={() => {
handleSubmit(saveUserLabel)();
}}
icon={<CheckIcon />}
/>
<Button
data-testid="editUserLabel-cancelBtn"
variant="link"
className="kc-editUserLabel-cancelBtn"
onClick={toggle}
icon={<TimesIcon />}
/>
</div>
</>
) : (
<>
{credential.userLabel}
<Button
variant="link"
className="kc-editUserLabel-btn"
onClick={toggle}
data-testid="editUserLabelBtn"
icon={<PencilAltIcon />}
/>
</>
)}
</div>
</FormGroup>
</Form>
);
};

View file

@ -0,0 +1,38 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import { FormGroup } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { TimeSelector } from "../../components/time-selector/TimeSelector";
import { credResetFormDefaultValues } from "./ResetCredentialDialog";
export const LifespanField = () => {
const { t } = useTranslation("users");
const { control } = useFormContext();
return (
<FormGroup
fieldId="lifespan"
label={t("lifespan")}
isStack
labelIcon={
<HelpItem helpText="clients-help:lifespan" fieldLabelId="lifespan" />
}
>
<Controller
name="lifespan"
defaultValue={credResetFormDefaultValues.lifespan}
control={control}
render={({ onChange, value }) => (
<TimeSelector
value={value}
units={["minutes", "hours", "days"]}
onChange={onChange}
menuAppendTo="parent"
/>
)}
/>
</FormGroup>
);
};

View file

@ -0,0 +1,94 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { ModalVariant, Form, AlertVariant } from "@patternfly/react-core";
import type { RequiredActionAlias } from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation";
import { CredentialsResetActionMultiSelect } from "./CredentialsResetActionMultiSelect";
import { ConfirmDialogModal } from "../../components/confirm-dialog/ConfirmDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
import { LifespanField } from "./LifespanField";
import { isEmpty } from "lodash-es";
type ResetCredentialDialogProps = {
userId: string;
onClose: () => void;
};
type CredentialResetForm = {
actions: RequiredActionAlias[];
lifespan: number;
};
export const credResetFormDefaultValues: CredentialResetForm = {
actions: [],
lifespan: 43200, // 12 hours
};
export const ResetCredentialDialog = ({
userId,
onClose,
}: ResetCredentialDialogProps) => {
const { t } = useTranslation("users");
const form = useForm<CredentialResetForm>({
defaultValues: credResetFormDefaultValues,
});
const { handleSubmit, control } = form;
const resetActionWatcher = useWatch<CredentialResetForm["actions"]>({
control: control,
name: "actions",
});
const resetIsNotDisabled = !isEmpty(resetActionWatcher);
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const sendCredentialsResetEmail = async ({
actions,
lifespan,
}: CredentialResetForm) => {
if (isEmpty(actions)) {
return;
}
try {
await adminClient.users.executeActionsEmail({
id: userId,
actions,
lifespan,
});
addAlert(t("credentialResetEmailSuccess"), AlertVariant.success);
onClose();
} catch (error) {
addError("users:credentialResetEmailError", error);
}
};
return (
<ConfirmDialogModal
variant={ModalVariant.medium}
titleKey="users:credentialReset"
open
onCancel={onClose}
toggleDialog={onClose}
continueButtonLabel="users:credentialResetConfirm"
onConfirm={() => {
handleSubmit(sendCredentialsResetEmail)();
}}
confirmButtonDisabled={!resetIsNotDisabled}
>
<Form
id="userCredentialsReset-form"
isHorizontal
data-testid="credential-reset-modal"
>
<FormProvider {...form}>
<CredentialsResetActionMultiSelect />
<LifespanField />
</FormProvider>
</Form>
</ConfirmDialogModal>
);
};

View file

@ -0,0 +1,212 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import {
AlertVariant,
ButtonVariant,
Form,
FormGroup,
Switch,
ValidatedOptions,
} from "@patternfly/react-core";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { PasswordInput } from "../../components/password-input/PasswordInput";
import {
ConfirmDialogModal,
useConfirmDialog,
} from "../../components/confirm-dialog/ConfirmDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
import useToggle from "../../utils/useToggle";
type ResetPasswordDialogProps = {
user: UserRepresentation;
isResetPassword: boolean;
refresh: () => void;
onClose: () => void;
};
export type CredentialsForm = {
password: string;
passwordConfirmation: string;
temporaryPassword: boolean;
};
const credFormDefaultValues: CredentialsForm = {
password: "",
passwordConfirmation: "",
temporaryPassword: true,
};
export const ResetPasswordDialog = ({
user,
isResetPassword,
refresh,
onClose,
}: ResetPasswordDialogProps) => {
const { t } = useTranslation("users");
const {
register,
control,
errors,
formState: { isValid },
watch,
handleSubmit,
} = useForm<CredentialsForm>({
defaultValues: credFormDefaultValues,
mode: "onChange",
shouldUnregister: false,
});
const [confirm, toggle] = useToggle(true);
const password = watch("password", "");
const adminClient = useAdminClient();
const { addAlert, addError } = useAlerts();
const [toggleConfirmSaveModal, ConfirmSaveModal] = useConfirmDialog({
titleKey: isResetPassword
? "users:resetPasswordConfirm"
: "users:setPasswordConfirm",
messageKey: isResetPassword
? t("resetPasswordConfirmText", { username: user.username })
: t("setPasswordConfirmText", { username: user.username }),
continueButtonLabel: isResetPassword
? "users:resetPassword"
: "users:savePassword",
continueButtonVariant: ButtonVariant.danger,
onConfirm: () => handleSubmit(saveUserPassword)(),
});
const saveUserPassword = async ({
password,
temporaryPassword,
}: CredentialsForm) => {
try {
await adminClient.users.resetPassword({
id: user.id!,
credential: {
temporary: temporaryPassword,
type: "password",
value: password,
},
});
addAlert(
isResetPassword
? t("resetCredentialsSuccess")
: t("savePasswordSuccess"),
AlertVariant.success
);
refresh();
} catch (error) {
addError(
isResetPassword
? "users:resetPasswordError"
: "users:savePasswordError",
error
);
}
onClose();
};
return (
<>
<ConfirmSaveModal />
<ConfirmDialogModal
titleKey={
isResetPassword
? t("resetPasswordFor", { username: user.username })
: t("setPasswordFor", { username: user.username })
}
open={confirm}
onCancel={onClose}
toggleDialog={toggle}
onConfirm={toggleConfirmSaveModal}
confirmButtonDisabled={!isValid}
continueButtonLabel="common:save"
>
<Form
id="userCredentials-form"
isHorizontal
className="keycloak__user-credentials__reset-form"
>
<FormGroup
name="password"
label={t("password")}
fieldId="password"
helperTextInvalid={t("common:required")}
validated={
errors.password
? ValidatedOptions.error
: ValidatedOptions.default
}
isRequired
>
<PasswordInput
data-testid="passwordField"
name="password"
aria-label="password"
ref={register({ required: true })}
/>
</FormGroup>
<FormGroup
name="passwordConfirmation"
label={
isResetPassword
? t("resetPasswordConfirmation")
: t("passwordConfirmation")
}
fieldId="passwordConfirmation"
helperTextInvalid={errors.passwordConfirmation?.message}
validated={
errors.passwordConfirmation
? ValidatedOptions.error
: ValidatedOptions.default
}
isRequired
>
<PasswordInput
data-testid="passwordConfirmationField"
name="passwordConfirmation"
aria-label="passwordConfirm"
ref={register({
required: true,
validate: (value) =>
value === password ||
t("confirmPasswordDoesNotMatch").toString(),
})}
/>
</FormGroup>
<FormGroup
label={t("common:temporaryPassword")}
labelIcon={
<HelpItem
helpText="temporaryPasswordHelpText"
fieldLabelId="temporaryPassword"
/>
}
fieldId="kc-temporaryPassword"
>
<Controller
name="temporaryPassword"
defaultValue={true}
control={control}
render={({ onChange, value }) => (
<Switch
className="kc-temporaryPassword"
onChange={onChange}
isChecked={value}
label={t("common:on")}
labelOff={t("common:off")}
/>
)}
/>
</FormGroup>
</Form>
</ConfirmDialogModal>
</>
);
};