Credentials Reset Flow (#1699)

* Add credential reset modal

* Add i18n labels

* Refactor to align with marvelapp mockup
TODO: tests

* Add e2e tests

* Implement code review change requests
Add menuAppendTo to CredentialResetActionMultiSelect -> Select component
Add optional menuAppendTo prop to TimeSelectorComponent
Refactor CredentialsPage constructor
This commit is contained in:
Marco 2021-12-21 07:22:44 +01:00 committed by GitHub
parent fd6e433c9c
commit 2276311334
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 562 additions and 168 deletions

View file

@ -10,6 +10,7 @@ import { keycloakBefore } from "../support/util/keycloak_before";
import GroupModal from "../support/pages/admin_console/manage/groups/GroupModal";
import UserGroupsPage from "../support/pages/admin_console/manage/users/UserGroupsPage";
import AdminClient from "../support/util/AdminClient";
import CredentialsPage from "../support/pages/admin_console/manage/users/CredentialsPage";
let groupName = "group";
let groupsList: string[] = [];
@ -54,9 +55,11 @@ describe("Users test", () => {
const modalUtils = new ModalUtils();
const listingPage = new ListingPage();
const userDetailsPage = new UserDetailsPage();
const credentialsPage = new CredentialsPage();
const attributesTab = new AttributesTab();
let itemId = "user_crud";
let itemIdWithCred = "user_crud_cred";
describe("User creation", () => {
beforeEach(() => {
@ -106,6 +109,34 @@ describe("Users test", () => {
sidebarPage.goToUsers();
});
it("Create user with credentials test", () => {
itemIdWithCred += "_" + (Math.random() + 1).toString(36).substring(7);
createUserPage.goToCreateUser();
createUserPage.createUser(itemIdWithCred);
createUserPage.save();
masthead.checkNotificationMessage("The user has been created");
sidebarPage.goToUsers();
listingPage.goToItemDetails(itemIdWithCred);
userDetailsPage.fillUserData().save();
masthead.checkNotificationMessage("The user has been saved");
credentialsPage
.goToCredentialsTab()
.clickEmptyStatePasswordBtn()
.fillPasswordForm()
.clickConfirmationBtn()
.clickSetPasswordBtn();
sidebarPage.goToUsers();
});
it("User details test", () => {
cy.wait("@brute-force");
listingPage.searchItem(itemId).itemExist(itemId);
@ -199,6 +230,27 @@ describe("Users test", () => {
cy.findByTestId("empty-state").contains("No consents");
});
it("Reset credential of User with empty state", () => {
cy.wait("@brute-force");
listingPage.goToItemDetails(itemId);
credentialsPage
.goToCredentialsTab()
.clickEmptyStateResetBtn()
.fillResetCredentialForm();
masthead.checkNotificationMessage("Failed to send email to user.");
});
it("Reset credential of User with existing credentials", () => {
cy.wait("@brute-force");
listingPage.goToItemDetails(itemIdWithCred);
credentialsPage
.goToCredentialsTab()
.clickResetBtn()
.fillResetCredentialForm();
masthead.checkNotificationMessage("Failed to send email to user.");
});
it("Delete user test", () => {
// Delete
listingPage.deleteItem(itemId);
@ -209,5 +261,16 @@ describe("Users test", () => {
listingPage.itemExist(itemId, false);
});
it("Delete user with credential test", () => {
// Delete
listingPage.deleteItem(itemIdWithCred);
modalUtils.checkModalTitle("Delete user?").confirmModal();
masthead.checkNotificationMessage("The user has been deleted");
listingPage.itemExist(itemIdWithCred, false);
});
});
});

View file

@ -0,0 +1,86 @@
export default class CredentialsPage {
private readonly credentialsTab = "credentials";
private readonly emptyStatePasswordBtn = "no-credentials-empty-action";
private readonly emptyStateResetBtn = "credential-reset-empty-action";
private readonly resetBtn = "credentialResetBtn";
private readonly setPasswordBtn = "setPasswordBtn";
private readonly credentialResetModal = "credential-reset-modal";
private readonly resetModalActionsToggleBtn =
"[data-testid=credential-reset-modal] #actions";
private readonly passwordField =
".kc-password > .pf-c-input-group > .pf-c-form-control";
private readonly passwordConfirmationField =
".kc-passwordConfirmation > .pf-c-input-group > .pf-c-form-control";
private readonly resetActions = [
"VERIFY_EMAIL-option",
"UPDATE_PROFILE-option",
"CONFIGURE_TOTP-option",
"UPDATE_PASSWORD-option",
"terms_and_conditions-option",
];
private readonly confirmationButton = "okBtn";
goToCredentialsTab() {
cy.findByTestId(this.credentialsTab).click();
return this;
}
clickEmptyStatePasswordBtn() {
cy.findByTestId(this.emptyStatePasswordBtn).click();
return this;
}
clickEmptyStateResetBtn() {
cy.findByTestId(this.emptyStateResetBtn).click();
return this;
}
clickResetBtn() {
cy.findByTestId(this.resetBtn).click();
return this;
}
clickResetModalActionsToggleBtn() {
cy.get(this.resetModalActionsToggleBtn).click();
return this;
}
clickResetModalAction(index: number) {
cy.findByTestId(this.resetActions[index]).click();
return this;
}
clickConfirmationBtn() {
cy.findByTestId(this.confirmationButton).dblclick();
return this;
}
fillPasswordForm() {
cy.get(this.passwordField).type("test");
cy.get(this.passwordConfirmationField).type("test");
return this;
}
fillResetCredentialForm() {
cy.findByTestId(this.credentialResetModal);
this.clickResetModalActionsToggleBtn()
.clickResetModalAction(2)
.clickResetModalAction(3)
.clickConfirmationBtn();
return this;
}
clickSetPasswordBtn() {
cy.findByTestId(this.setPasswordBtn).click();
return this;
}
}

View file

@ -4,7 +4,7 @@ export default class UserDetailsPage {
saveBtn: string;
cancelBtn: string;
emailInput: string;
emailValue: string;
emailValue: () => string;
firstNameInput: string;
firstNameValue: string;
lastNameInput: string;
@ -17,7 +17,7 @@ export default class UserDetailsPage {
this.saveBtn = "save-user";
this.cancelBtn = "cancel-create-user";
this.emailInput = "email-input";
this.emailValue =
this.emailValue = () =>
"example" +
"_" +
(Math.random() + 1).toString(36).substring(7) +
@ -32,7 +32,7 @@ export default class UserDetailsPage {
}
fillUserData() {
cy.findByTestId(this.emailInput).type(this.emailValue);
cy.findByTestId(this.emailInput).type(this.emailValue());
cy.findByTestId(this.firstNameInput).type(this.firstNameValue);
cy.findByTestId(this.lastNameInput).type(this.lastNameValue);
cy.findByTestId(this.enabledSwitch).check({ force: true });

View file

@ -173,5 +173,8 @@ export default {
ownerManagedAccess:
"If enabled, the access to this resource can be managed by the resource owner.",
resourceAttribute: "The attributes associated wth the resource.",
resetActions:
"Set of actions to execute when sending the user a Reset Actions Email. '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.",
lifespan: "Maximum time before the action permit expires.",
},
};

View file

@ -66,6 +66,9 @@ export const ListEmptyState = ({
{secondaryActions.map((action) => (
<Button
key={action.text}
data-testid={`${action.text
.replace(/\W+/g, "-")
.toLowerCase()}-empty-action`}
variant={action.type || ButtonVariant.secondary}
onClick={action.onClick}
>

View file

@ -6,13 +6,15 @@ import {
SplitItem,
TextInput,
TextInputProps,
ToggleMenuBaseProps,
} from "@patternfly/react-core";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export type Unit = "seconds" | "minutes" | "hours" | "days";
export type TimeSelectorProps = TextInputProps & {
export type TimeSelectorProps = TextInputProps &
ToggleMenuBaseProps & {
value: number;
units?: Unit[];
onChange: (time: number | string) => void;
@ -25,6 +27,7 @@ export const TimeSelector = ({
onChange,
className,
min,
menuAppendTo,
...rest
}: TimeSelectorProps) => {
const { t } = useTranslation("common");
@ -102,6 +105,7 @@ export const TimeSelector = ({
updateTimeout(timeValue, value as number);
setOpen(false);
}}
menuAppendTo={menuAppendTo}
selections={[multiplier]}
onToggle={() => {
setOpen(!open);

View file

@ -12,6 +12,9 @@ import {
KebabToggle,
Modal,
ModalVariant,
Select,
SelectOption,
SelectVariant,
Switch,
Text,
TextInput,
@ -33,17 +36,20 @@ import {
import { PencilAltIcon, CheckIcon, TimesIcon } from "@patternfly/react-icons";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { useTranslation } from "react-i18next";
import { isEmpty } from "lodash/fp";
import { useAlerts } from "../components/alert/Alerts";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useWhoAmI } from "../context/whoami/WhoAmI";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Controller, useForm, UseFormMethods, useWatch } from "react-hook-form";
import { PasswordInput } from "../components/password-input/PasswordInput";
import { HelpItem } from "../components/help-enabler/HelpItem";
import "./user-section.css";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation";
import { FormAccess } from "../components/form-access/FormAccess";
import { RequiredActionAlias } from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation";
import { TimeSelector } from "../components/time-selector/TimeSelector";
type UserCredentialsProps = {
user: UserRepresentation;
@ -55,12 +61,22 @@ type CredentialsForm = {
temporaryPassword: boolean;
};
type CredentialResetForm = {
actions: RequiredActionAlias[];
lifespan: number;
};
const credFormDefaultValues: CredentialsForm = {
password: "",
passwordConfirmation: "",
temporaryPassword: true,
};
const credResetFormDefaultValues: CredentialResetForm = {
actions: [],
lifespan: 43200, // 12 hours
};
type DisplayDialogProps = {
titleKey: string;
onClose: () => void;
@ -92,6 +108,102 @@ const DisplayDialog: FunctionComponent<DisplayDialogProps> = ({
);
};
const CredentialsResetActionMultiSelect = (props: {
form: UseFormMethods<CredentialResetForm>;
}) => {
const { t } = useTranslation("users");
const [open, setOpen] = useState(false);
const { form } = props;
const { control } = form;
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.map((o: string) => o)}
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>
);
};
const LifespanField = ({
form: { control },
}: {
form: UseFormMethods<CredentialResetForm>;
}) => {
const { t } = useTranslation("users");
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>
);
};
export const UserCredentials = ({ user }: UserCredentialsProps) => {
const { t } = useTranslation("users");
const { whoAmI } = useWhoAmI();
@ -100,6 +212,7 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
const refresh = () => setKey(key + 1);
const [open, setOpen] = useState(false);
const [openSaveConfirm, setOpenSaveConfirm] = useState(false);
const [openCredentialReset, setOpenCredentialReset] = useState(false);
const [kebabOpen, setKebabOpen] = useState({
status: false,
rowKey: "",
@ -108,16 +221,23 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
const form = useForm<CredentialsForm>({
defaultValues: credFormDefaultValues,
});
const resetForm = useForm<CredentialResetForm>({
defaultValues: credResetFormDefaultValues,
});
const userLabelForm = useForm<UserLabelForm>({
defaultValues: userLabelDefaultValues,
});
const { control, errors, handleSubmit, register } = form;
const { control: resetControl, handleSubmit: resetHandleSubmit } = resetForm;
const {
getValues: getValues1,
handleSubmit: handleSubmit1,
register: register1,
} = userLabelForm;
const [credentials, setCredentials] = useState<CredentialsForm>();
const [credentialsReset, setCredentialReset] = useState<CredentialResetForm>(
{} as CredentialResetForm
);
const [userCredentials, setUserCredentials] = useState<
CredentialRepresentation[]
>([]);
@ -149,6 +269,11 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
name: "password",
});
const resetActionWatcher = useWatch<CredentialResetForm["actions"]>({
control: resetControl,
name: "actions",
});
const passwordConfirmationWatcher = useWatch<
CredentialsForm["passwordConfirmation"]
>({
@ -159,10 +284,16 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
const isNotDisabled =
passwordWatcher !== "" && passwordConfirmationWatcher !== "";
const resetIsNotDisabled = !isEmpty(resetActionWatcher);
const toggleModal = () => {
setOpen(!open);
};
const toggleCredentialsResetModal = () => {
setOpenCredentialReset(!openCredentialReset);
};
const toggleConfirmSaveModal = () => {
setOpenSaveConfirm(!openSaveConfirm);
};
@ -212,6 +343,25 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
}
};
const sendCredentialsResetEmail = async () => {
if (isEmpty(credentialsReset.actions)) {
return;
}
try {
await adminClient.users.executeActionsEmail({
id: user.id!,
actions: credentialsReset.actions,
lifespan: credentialsReset.lifespan,
});
refresh();
addAlert(t("credentialResetEmailSuccess"), AlertVariant.success);
setOpenCredentialReset(false);
} catch (error) {
addError(t("credentialResetEmailError"), error);
}
};
const resetPassword = () => {
setIsResetPassword(true);
setOpen(true);
@ -387,7 +537,6 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
}
fieldId="kc-temporaryPassword"
>
{" "}
<Controller
name="temporaryPassword"
defaultValue={true}
@ -453,6 +602,48 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
</Text>
</Modal>
)}
{openCredentialReset && (
<Modal
variant={ModalVariant.medium}
title={t("credentialReset")}
isOpen
onClose={() => {
setOpenCredentialReset(false);
}}
data-testid="credential-reset-modal"
actions={[
<Button
data-testid="okBtn"
key={`confirmBtn-${user.id}`}
variant="primary"
form="userCredentialsReset-form"
onClick={() => {
setCredentialReset(resetForm.getValues());
resetHandleSubmit(sendCredentialsResetEmail)();
}}
isDisabled={!resetIsNotDisabled}
>
{t("credentialResetConfirm")}
</Button>,
<Button
data-testid="cancelBtn"
key={`cancelBtn-${user.id}`}
variant="link"
form="userCredentialsReset-form"
onClick={() => {
setOpenCredentialReset(false);
}}
>
{t("cancel")}
</Button>,
]}
>
<Form id="userCredentialsReset-form" isHorizontal>
<CredentialsResetActionMultiSelect form={resetForm} />
<LifespanField form={resetForm} />
</Form>
</Modal>
)}
<DeleteConfirm />
{showData && Object.keys(selectedCredential).length !== 0 && (
<DisplayDialog
@ -492,6 +683,17 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
</>
)}
{userCredentials.length !== 0 ? (
<>
{user.email && (
<Button
className="resetCredentialBtn-header"
variant="primary"
data-testid="credentialResetBtn"
onClick={() => setOpenCredentialReset(true)}
>
{t("credentialResetBtn")}
</Button>
)}
<TableComposable aria-label="password-data-table" variant={"compact"}>
<Thead>
<Tr>
@ -664,6 +866,7 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
))}
</Tbody>
</TableComposable>
</>
) : (
<ListEmptyState
hasIcon={true}
@ -671,6 +874,17 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
instructions={t("noCredentialsText")}
primaryActionText={t("setPassword")}
onPrimaryAction={toggleModal}
secondaryActions={
user.email
? [
{
text: t("credentialResetBtn"),
onClick: toggleCredentialsResetModal,
type: ButtonVariant.link,
},
]
: undefined
}
/>
)}
</>

View file

@ -169,5 +169,21 @@ export default {
updateCredentialUserLabelSuccess:
"The user label has been changed successfully.",
updateCredentialUserLabelError: "Error changing user label: {{error}}",
credentialReset: "Credentials Reset",
credentialResetBtn: "Credential Reset",
resetActions: "Reset Actions",
lifespan: "Expires In",
VERIFY_EMAIL: "Verify Email (VERIFY_EMAIL)",
UPDATE_PASSWORD: "Update password (UPDATE_PASSWORD)",
UPDATE_PROFILE: "Update Profile (UPDATE_PROFILE)",
CONFIGURE_TOTP: "Configure OTP (CONFIGURE_TOTP)",
terms_and_conditions: "Terms and Conditions (terms_and_conditions)",
hours: "Hours",
minutes: "Minutes",
seconds: "Seconds",
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.",
},
};

View file

@ -182,3 +182,8 @@ article.pf-c-card.pf-m-flat.kc-available-idps > div > div > h1 {
.setPasswordBtn-table {
margin: 25px 0 25px 25px;
}
.resetCredentialBtn-header {
margin: 10px 25px 10px 0;
float: right;
}