From 9ca6bb68731a93748ca2e786b76e9331381eaa97 Mon Sep 17 00:00:00 2001 From: Vito Galatro Date: Tue, 15 Aug 2023 10:21:16 -0400 Subject: [PATCH] THEMES-762: Identity Blocks | Reset Password block (#1717) * THEMES-762: began adding reset password block. * THEMES-762: converted reset password block and started fixing tests. * THEMES-762: cleaned up tests and fixed one that was breaking. * THEMES-762: cleaned up tests, added missing ones. * THEMES-762: added styles to the reset password form. * THEMES-762: updating styles to better match designs. * THEMES-762: small change to get coverage command to see test file. --- blocks/identity-block/_index.scss | 5 + .../form-password-confirm/index.test.jsx | 1 - .../headlined-submit-form/index.jsx | 1 - .../components/identity/index.test.jsx | 1 + .../identity-block/components/login/index.jsx | 11 +- .../features/login/default.test.jsx | 63 ++++-- .../features/reset-password/default.jsx | 181 ++++++++++++++++++ .../reset-password/default.story-ignore.jsx | 35 ++++ .../features/reset-password/default.test.jsx | 119 ++++++++++++ .../features/signup/default.test.jsx | 35 ++++ blocks/identity-block/themes/commerce.json | 73 ++++++- blocks/identity-block/themes/news.json | 50 +++++ .../utils/validate-redirect-url.js | 11 ++ .../utils/validate-redirect-url.test.js | 20 ++ 14 files changed, 566 insertions(+), 40 deletions(-) create mode 100644 blocks/identity-block/features/reset-password/default.jsx create mode 100644 blocks/identity-block/features/reset-password/default.story-ignore.jsx create mode 100644 blocks/identity-block/features/reset-password/default.test.jsx create mode 100644 blocks/identity-block/utils/validate-redirect-url.js create mode 100644 blocks/identity-block/utils/validate-redirect-url.test.js diff --git a/blocks/identity-block/_index.scss b/blocks/identity-block/_index.scss index 0b003732c7..155b37b59c 100644 --- a/blocks/identity-block/_index.scss +++ b/blocks/identity-block/_index.scss @@ -40,6 +40,11 @@ @include scss.block-properties("login-links"); } +.b-reset-password { + @include scss.block-components("reset-password"); + @include scss.block-properties("reset-password"); +} + .b-sign-up { &__tos-container { a { diff --git a/blocks/identity-block/components/form-password-confirm/index.test.jsx b/blocks/identity-block/components/form-password-confirm/index.test.jsx index 2138c80588..65ea9240df 100644 --- a/blocks/identity-block/components/form-password-confirm/index.test.jsx +++ b/blocks/identity-block/components/form-password-confirm/index.test.jsx @@ -5,7 +5,6 @@ import FormPasswordConfirm from "."; describe("Form Password Confirm", () => { it("renders with required items", () => { render(); - expect(screen.getByLabelText("Password")).not.toBeNull(); expect(screen.getByLabelText("Confirm")).not.toBeNull(); }); diff --git a/blocks/identity-block/components/headlined-submit-form/index.jsx b/blocks/identity-block/components/headlined-submit-form/index.jsx index 3e52720d29..84e1b07b74 100644 --- a/blocks/identity-block/components/headlined-submit-form/index.jsx +++ b/blocks/identity-block/components/headlined-submit-form/index.jsx @@ -11,7 +11,6 @@ const HeadlinedSubmitForm = ({ onSubmit = () => {}, }) => { const formRef = useRef(); - const handleSubmit = (event) => { event.preventDefault(); diff --git a/blocks/identity-block/components/identity/index.test.jsx b/blocks/identity-block/components/identity/index.test.jsx index e6b83cd3d9..3bd19bded7 100644 --- a/blocks/identity-block/components/identity/index.test.jsx +++ b/blocks/identity-block/components/identity/index.test.jsx @@ -37,6 +37,7 @@ describe("Identity useIdentity Hook", () => { expect(Identity).toBe(IdentityObject); return
; }; + render(); expect(IdentityObject.options).toHaveBeenLastCalledWith({ apiOrigin: "http://origin/", diff --git a/blocks/identity-block/components/login/index.jsx b/blocks/identity-block/components/login/index.jsx index 7dae4152fa..1b9374b1cb 100644 --- a/blocks/identity-block/components/login/index.jsx +++ b/blocks/identity-block/components/login/index.jsx @@ -1,15 +1,6 @@ import { useEffect, useState } from "react"; import useIdentity from "../identity"; - -const validateURL = (url) => { - if (!url) return null; - const validationRegEx = /^\/[^/].*$/; - const valid = validationRegEx.test(url); - if (valid) { - return url; - } - return "/"; -}; +import validateURL from "../../utils/validate-redirect-url"; const useLogin = ({ isAdmin, redirectURL, redirectToPreviousPage, loggedInPageLocation }) => { const { Identity } = useIdentity(); diff --git a/blocks/identity-block/features/login/default.test.jsx b/blocks/identity-block/features/login/default.test.jsx index c9d1ab4564..1eb5028a33 100644 --- a/blocks/identity-block/features/login/default.test.jsx +++ b/blocks/identity-block/features/login/default.test.jsx @@ -4,58 +4,61 @@ import Login from "./default"; import useIdentity from "../../components/identity"; jest.mock("../../components/identity"); +jest.mock("fusion:properties", () => jest.fn(() => ({}))); const defaultCustomFields = { redirectURL: "", redirectToPreviousPage: true, }; -const loginMock = jest.fn(() => Promise.resolve({})); +const loginMock = jest.fn(() => Promise.resolve()); +const loginMockFail = jest.fn(() => Promise.reject()); -jest.mock("fusion:properties", () => jest.fn(() => ({}))); +const IdentityDefaults = { + isLoggedIn: jest.fn(() => false), + getConfig: jest.fn(() => ({})), + login: loginMock, +}; describe("Identity Login Feature", () => { - beforeEach(async () => { + beforeEach(() => { useIdentity.mockImplementation(() => ({ isInitialized: true, Identity: { - isLoggedIn: jest.fn(async () => false), - getConfig: jest.fn(async () => ({})), - login: loginMock, + ...IdentityDefaults, }, })); }); - afterAll(() => { - jest.restoreAllMocks(); + afterEach(() => { + jest.clearAllMocks(); }); - it("renders nothing if identity not initialized", async () => { + it("renders nothing if identity not initialized", () => { useIdentity.mockImplementation(() => ({ isInitialized: false, Identity: { - isLoggedIn: jest.fn(async () => false), - getConfig: jest.fn(async () => ({})), + ...IdentityDefaults, }, })); - await render(); + render(); expect(screen.queryAllByRole("button")).toEqual([]); }); - it("renders", async () => { - await render(); + it("renders", () => { + render(); expect(screen.queryByRole("form")).not.toBeNull(); }); - it("shows login form", async () => { - await render(); + it("shows login form", () => { + render(); expect(screen.queryByRole("form")).not.toBeNull(); expect(screen.getByLabelText("identity-block.password")).not.toBeNull(); expect(screen.getByLabelText("identity-block.email")).not.toBeNull(); }); - it("submits the login form", async () => { - await render(); + it("submits the login form", () => { + render(); fireEvent.change(screen.getByLabelText("identity-block.email"), { target: { value: "email@test.com" }, @@ -65,8 +68,28 @@ describe("Identity Login Feature", () => { }); fireEvent.click(screen.getByRole("button")); - await loginMock; - expect(loginMock).toHaveBeenCalled(); }); + + it("rejects the login", async () => { + useIdentity.mockImplementation(() => ({ + isInitialized: true, + Identity: { + ...IdentityDefaults, + login: loginMockFail, + }, + })); + + render(); + + fireEvent.change(screen.getByLabelText("identity-block.email"), { + target: { value: "email@test.com" }, + }); + fireEvent.change(screen.getByLabelText("identity-block.password"), { + target: { value: "thisNotIsMyPassword" }, + }); + await fireEvent.click(screen.getByRole("button")); + await expect(loginMockFail).toHaveBeenCalled(); + expect(screen.getByText("identity-block.login-form-error")).not.toBeNull(); + }); }); diff --git a/blocks/identity-block/features/reset-password/default.jsx b/blocks/identity-block/features/reset-password/default.jsx new file mode 100644 index 0000000000..3171fef768 --- /dev/null +++ b/blocks/identity-block/features/reset-password/default.jsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState } from "react"; +import PropTypes from "@arc-fusion/prop-types"; +import { useFusionContext } from "fusion:context"; +import getTranslatedPhrases from "fusion:intl"; +import { Paragraph } from "@wpmedia/arc-themes-components"; +import FormPasswordConfirm from "../../components/form-password-confirm"; +import HeadlinedSubmitForm from "../../components/headlined-submit-form"; +import useIdentity from "../../components/identity"; +import passwordValidationMessage from "../../utils/password-validation-message"; +import validatePasswordPattern from "../../utils/validate-password-pattern"; +import validateURL from "../../utils/validate-redirect-url"; + +const BLOCK_CLASS_NAME = "b-reset-password"; + +const defaultSuccessURL = "/account/login/"; + +export const ResetPasswordPresentation = ({ isAdmin = false, phrases, successActionURL }) => { + const { Identity, isInitialized } = useIdentity(); + + const [error, setError] = useState(); + const [passwordRequirements, setPasswordRequirements] = useState({ + status: "initial", + }); + const [submitted, setSubmitted] = useState(false); + + // eslint doesn't handle globalThis yet, but this is appropriate + /* global globalThis */ + const nonce = new URLSearchParams(globalThis.location.search).get("nonce"); + + useEffect(() => { + const getConfig = async () => { + await Identity.getConfig() + .then((response) => { + const { pwLowercase, pwMinLength, pwPwNumbers, pwSpecialCharacters, pwUppercase } = + response; + + setPasswordRequirements({ + pwLowercase, + pwMinLength, + pwPwNumbers, + pwSpecialCharacters, + pwUppercase, + status: "success", + }); + }) + .catch(() => setPasswordRequirements({ status: "error" })); + }; + + if (Identity) { + getConfig(); + } + }, [Identity]); + + const { + pwLowercase = 0, + pwMinLength = 0, + pwPwNumbers = 0, + pwSpecialCharacters = 0, + pwUppercase = 0, + status, + } = passwordRequirements; + + const passwordErrorMessage = passwordValidationMessage({ + defaultMessage: phrases.t("identity-block.password-requirements"), + options: { + lowercase: { + value: pwLowercase, + message: phrases.t("identity-block.password-requirements-lowercase", { + requirementCount: pwLowercase, + }), + }, + minLength: { + value: pwMinLength, + message: phrases.t("identity-block.password-requirements-characters", { + requirementCount: pwMinLength, + }), + }, + uppercase: { + value: pwUppercase, + message: phrases.t("identity-block.password-requirements-uppercase", { + requirementCount: pwUppercase, + }), + }, + numbers: { + value: pwPwNumbers, + message: phrases.t("identity-block.password-requirements-numbers", { + requirementCount: pwPwNumbers, + }), + }, + specialCharacters: { + value: pwSpecialCharacters, + message: phrases.t("identity-block.password-requirements-uppercase", { + requirementCount: pwUppercase, + }), + }, + }, + }); + + if (isAdmin || (isInitialized && nonce)) { + if (submitted) { + return ( + { + const redirect = validateURL(successActionURL); + window.location.assign(redirect); + }} + > + {phrases.t("identity-block.reset-password-instruction-submitted")} + + ); + } + + return ( + { + if (!isAdmin) { + Identity.resetPassword(nonce, newPassword) + .then(() => { + setSubmitted(true); + }) + .catch(() => setError(phrases.t("identity-block.reset-password-error"))); + } + }} + > + {phrases.t("identity-block.reset-password-instruction")} + + + ); + } + return null; +}; + +const ResetPassword = ({ customFields }) => { + const { successActionURL = defaultSuccessURL } = customFields; + const { isAdmin, siteProperties } = useFusionContext(); + const { locale } = siteProperties; + const phrases = getTranslatedPhrases(locale); + + return ( + + ); +}; + +ResetPassword.label = "Identity Reset Password - Arc Block"; + +ResetPassword.icon = "redo"; + +ResetPassword.propTypes = { + customFields: PropTypes.shape({ + successActionURL: PropTypes.string.tag({ + name: "Successful Action URL", + defaultValue: "/account/login/", + }), + }), +}; + +export default ResetPassword; diff --git a/blocks/identity-block/features/reset-password/default.story-ignore.jsx b/blocks/identity-block/features/reset-password/default.story-ignore.jsx new file mode 100644 index 0000000000..99a8828739 --- /dev/null +++ b/blocks/identity-block/features/reset-password/default.story-ignore.jsx @@ -0,0 +1,35 @@ +import React from "react"; + +import { ResetPasswordPresentation } from "./default"; + +export default { + title: "Blocks/Identity/Blocks/Reset Password", + parameters: { + chromatic: { viewports: [320, 1200] }, + }, +}; + +const phrases = { + t: (phrase) => + ({ + "identity-block.confirm-password": "Confirm Password", + "identity-block.confirm-password-error": "Passwords must match", + "identity-block.password": "Password", + "identity-block.password-requirements": "Passwords must meet our strict requirements.", + "identity-block.password-requirements-characters": "Characters", + "identity-block.password-requirements-lowercase": "Lowercase", + "identity-block.password-requirements-numbers": "Numbers", + "identity-block.password-requirements-uppercase": "Uppercase", + "identity-block.reset-password-error": "There was an error with your password", + "identity-block.reset-password-headline": "Create your new password", + "identity-block.reset-password-headline-submitted": "New password saved", + "identity-block.reset-password-instruction": "Enter a new password for your account.", + "identity-block.reset-password-instruction-submitted": "Your new password has been saved.", + "identity-block.reset-password-submit": "Continue", + "identity-block.reset-password-submit-submitted": "Continue to Login", + }[phrase]), +}; + +export const basic = () => ( + +); diff --git a/blocks/identity-block/features/reset-password/default.test.jsx b/blocks/identity-block/features/reset-password/default.test.jsx new file mode 100644 index 0000000000..48ac0943b7 --- /dev/null +++ b/blocks/identity-block/features/reset-password/default.test.jsx @@ -0,0 +1,119 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import ResetPassword from "./default"; +import FormPasswordConfirm from "../../components/form-password-confirm"; +import useIdentity from "../../components/identity"; + +const successActionURL = "/account/login/"; + +jest.mock("../../components/form-password-confirm"); +jest.mock("../../components/identity"); + +jest.mock("fusion:context", () => ({ + useFusionContext: jest.fn(() => ({ + arcSite: "arc-demo-5", + siteProperties: { + locale: "en", + }, + })), +})); + +FormPasswordConfirm.mockImplementation(() => ( + +)); + +const resetPasswordMock = jest.fn(() => Promise.resolve()); +const resetPasswordMockFail = jest.fn(() => Promise.reject()); + +const Identity = { + getConfig: () => Promise.resolve({}), + resetPassword: resetPasswordMock, +}; + +window.history.pushState({}, "", "/?nonce=abcd"); + +describe("Identity Password Reset Feature - unInitialized", () => { + beforeAll(() => { + useIdentity.mockImplementation(() => ({ + isInitialized: false, + Identity, + })); + }); + afterAll(() => { + jest.clearAllMocks(); + }); + + it("renders nothing if identity not initialized", () => { + render(); + expect(screen.queryByRole("form")).toBeNull(); + }); +}); + +describe("Identity Password Reset Feature", () => { + const assignMock = jest.fn(); + beforeAll(() => { + useIdentity.mockImplementation(() => ({ + isInitialized: true, + Identity, + })); + Object.defineProperty(window, "location", { + value: { + ...window.location, + assign: assignMock, + }, + writable: true, + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it("renders", () => { + render(); + expect(screen.getByRole("form")).not.toBeNull(); + }); + + it("shows submit form", () => { + render(); + expect(screen.getByText("identity-block.reset-password-headline")).not.toBeNull(); + expect(screen.getByText("identity-block.reset-password-instruction")).not.toBeNull(); + expect(screen.getByText("identity-block.reset-password-submit")).not.toBeNull(); + }); + + it("updates the page on submit and redirects the user to login when done", async () => { + render(); + await fireEvent.click(screen.getByRole("button")); + expect(resetPasswordMock).toHaveBeenCalled(); + expect(screen.getByText("identity-block.reset-password-headline-submitted")).not.toBeNull(); + expect(screen.getByText("identity-block.reset-password-instruction-submitted")).not.toBeNull(); + expect(screen.getByText("identity-block.reset-password-submit-submitted")).not.toBeNull(); + fireEvent.click(screen.getByRole("button")); + expect(assignMock).toHaveBeenCalled(); + }); +}); + +describe("Identity Password Reset Feature - Failing reset request", () => { + beforeAll(() => { + useIdentity.mockImplementation(() => ({ + isInitialized: true, + Identity: { + ...Identity, + resetPassword: resetPasswordMockFail, + }, + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it("updates the page on submit to failing state", async () => { + render(); + await fireEvent.click(screen.getByRole("button")); + await expect(resetPasswordMockFail).toHaveBeenCalled(); + expect(screen.getByText("identity-block.reset-password-error")).not.toBeNull(); + }); +}); + +window.history.back(); diff --git a/blocks/identity-block/features/signup/default.test.jsx b/blocks/identity-block/features/signup/default.test.jsx index b4ad7b465e..e4ba35a3b9 100644 --- a/blocks/identity-block/features/signup/default.test.jsx +++ b/blocks/identity-block/features/signup/default.test.jsx @@ -60,4 +60,39 @@ describe("With initialized identity", () => { } ); }); + + it("rejects the form", async () => { + const signUpMock = jest.fn(() => Promise.reject()); + useIdentity.mockImplementation( + jest.fn(() => ({ + Identity: { + getConfig: jest.fn(() => Promise.resolve()), + signUp: signUpMock, + }, + isInitialized: true, + })) + ); + render(); + + fireEvent.change(screen.getByLabelText("identity-block.email"), { + target: { value: "email-already-exists@test.com" }, + }); + fireEvent.change(screen.getByLabelText("identity-block.password"), { + target: { value: "thisIsMyPassword" }, + }); + fireEvent.change(screen.getByLabelText("identity-block.confirm-password"), { + target: { value: "thisIsMyPassword" }, + }); + await fireEvent.click(screen.getByRole("button")); + await expect(signUpMock).toHaveBeenCalledWith( + { + userName: "email-already-exists@test.com", + credentials: "thisIsMyPassword", + }, + { + email: "email-already-exists@test.com", + } + ); + expect(screen.getByText("identity-block.sign-up-form-error")).not.toBeNull(); + }); }); diff --git a/blocks/identity-block/themes/commerce.json b/blocks/identity-block/themes/commerce.json index d22ad45d84..8b97446b8e 100644 --- a/blocks/identity-block/themes/commerce.json +++ b/blocks/identity-block/themes/commerce.json @@ -82,10 +82,7 @@ "login-form": { "styles": { "default": { - "inline-size": "calc(100% - var(--global-spacing-6))", - "margin": "auto", "font-family": "var(--font-family-primary)", - "max-inline-size": "none", "components": { "button": { "font-size": "var(--global-font-size-4)" @@ -122,7 +119,71 @@ } }, "desktop": { - "max-inline-size": "37rem", + "components": { + "heading": { + "font-size": "var(--global-font-size-12)", + "padding-block-end": "var(--global-spacing-4)" + } + } + } + } + }, + "login-links": { + "styles": { + "default": { + "components": { + "link": { + "text-align": "center" + }, + "link-hover": { + "color": "var(--text-color-subtle)" + } + } + }, + "desktop": {} + } + }, + "reset-password": { + "styles": { + "default": { + "font-family": "var(--font-family-primary)", + "components": { + "button": { + "font-size": "var(--global-font-size-4)" + }, + "heading": { + "border-block-end-color": "var(--border-color)", + "border-block-end-style": "var(--global-border-style-1)", + "border-block-end-width": "var(--global-border-width-1)", + "font-size": "var(--global-font-size-9)", + "margin-block-end": "var(--global-spacing-4)", + "margin-bottom": "var(--global-spacing-4)", + "padding-block-end": "var(--global-spacing-2)", + "text-align": "center" + }, + "input": { + "margin-block-end": "var(--global-spacing-5)" + }, + "input-error-tip": { + "color": "var(--status-color-danger)" + }, + "input-input": { + "inline-size": "100%", + "width": "100%", + "padding-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-2)", + "padding-inline-end": "var(--global-spacing-2)", + "padding-inline-start": "var(--global-spacing-2)", + "padding-left": "var(--global-spacing-2)" + }, + "paragraph": { + "font-family": "var(--font-family-primary)", + "margin-block-end": "var(--global-spacing-4)", + "text-align": "center" + } + } + }, + "desktop": { "components": { "heading": { "font-size": "var(--global-font-size-12)", @@ -135,10 +196,7 @@ "sign-up": { "styles": { "default": { - "inline-size": "calc(100% - var(--global-spacing-6))", - "margin": "auto", "font-family": "var(--font-family-primary)", - "max-inline-size": "none", "components": { "button": { "font-size": "var(--global-font-size-4)" @@ -175,7 +233,6 @@ } }, "desktop": { - "max-inline-size": "37rem", "components": { "heading": { "font-size": "var(--global-font-size-12)", diff --git a/blocks/identity-block/themes/news.json b/blocks/identity-block/themes/news.json index 2c048c0ed9..8b97446b8e 100644 --- a/blocks/identity-block/themes/news.json +++ b/blocks/identity-block/themes/news.json @@ -143,6 +143,56 @@ "desktop": {} } }, + "reset-password": { + "styles": { + "default": { + "font-family": "var(--font-family-primary)", + "components": { + "button": { + "font-size": "var(--global-font-size-4)" + }, + "heading": { + "border-block-end-color": "var(--border-color)", + "border-block-end-style": "var(--global-border-style-1)", + "border-block-end-width": "var(--global-border-width-1)", + "font-size": "var(--global-font-size-9)", + "margin-block-end": "var(--global-spacing-4)", + "margin-bottom": "var(--global-spacing-4)", + "padding-block-end": "var(--global-spacing-2)", + "text-align": "center" + }, + "input": { + "margin-block-end": "var(--global-spacing-5)" + }, + "input-error-tip": { + "color": "var(--status-color-danger)" + }, + "input-input": { + "inline-size": "100%", + "width": "100%", + "padding-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-2)", + "padding-inline-end": "var(--global-spacing-2)", + "padding-inline-start": "var(--global-spacing-2)", + "padding-left": "var(--global-spacing-2)" + }, + "paragraph": { + "font-family": "var(--font-family-primary)", + "margin-block-end": "var(--global-spacing-4)", + "text-align": "center" + } + } + }, + "desktop": { + "components": { + "heading": { + "font-size": "var(--global-font-size-12)", + "padding-block-end": "var(--global-spacing-4)" + } + } + } + } + }, "sign-up": { "styles": { "default": { diff --git a/blocks/identity-block/utils/validate-redirect-url.js b/blocks/identity-block/utils/validate-redirect-url.js new file mode 100644 index 0000000000..df7514b9b0 --- /dev/null +++ b/blocks/identity-block/utils/validate-redirect-url.js @@ -0,0 +1,11 @@ +const validateURL = (url) => { + if (!url) return null; + const validationRegEx = /^\/[^/].*$/; + const valid = validationRegEx.test(url); + if (valid) { + return url; + } + return "/"; +}; + +export default validateURL; diff --git a/blocks/identity-block/utils/validate-redirect-url.test.js b/blocks/identity-block/utils/validate-redirect-url.test.js new file mode 100644 index 0000000000..702f8fdadb --- /dev/null +++ b/blocks/identity-block/utils/validate-redirect-url.test.js @@ -0,0 +1,20 @@ +import validateURL from "./validate-redirect-url"; + +describe("validateURL()", () => { + it("returns null when nothing is passed", () => { + const result = validateURL(); + expect(result).toBeNull(); + }); + + it("reutrns URL when it is a page on the current site", () => { + const url = "/redirect-here"; + const result = validateURL(url); + expect(result).toBe(url); + }); + + it("reutrns the root URL when potentially unsafe", () => { + const url = "https://www.unkown.com/redirect-here"; + const result = validateURL(url); + expect(result).toBe("/"); + }); +});