From f8cf34e34dd4cc1b9851a63cd920c8f734552f2c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 13 Jun 2019 21:37:51 +0000 Subject: [PATCH] feat: added email confirmation UI (#2358) --- src/core/client/account/routeConfig.tsx | 4 + .../email/Confirm/CheckConfirmTokenFetch.tsx | 14 ++ .../routes/email/Confirm/ConfirmForm.tsx | 69 ++++++++ .../routes/email/Confirm/ConfirmMutation.tsx | 14 ++ .../routes/email/Confirm/ConfirmRoute.tsx | 33 ++++ .../email/Confirm/ConfirmTokenChecker.tsx | 93 +++++++++++ .../account/routes/email/Confirm/Sorry.tsx | 23 +++ .../account/routes/email/Confirm/Success.tsx | 21 +++ .../account/routes/email/Confirm/index.ts | 1 + .../account/routes/password/Reset/Success.tsx | 4 +- .../__snapshots__/confirmEmail.spec.tsx.snap | 86 ++++++++++ .../client/account/test/confirmEmail.spec.tsx | 151 ++++++++++++++++++ src/locales/en-US/account.ftl | 11 ++ 13 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 src/core/client/account/routes/email/Confirm/CheckConfirmTokenFetch.tsx create mode 100644 src/core/client/account/routes/email/Confirm/ConfirmForm.tsx create mode 100644 src/core/client/account/routes/email/Confirm/ConfirmMutation.tsx create mode 100644 src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx create mode 100644 src/core/client/account/routes/email/Confirm/ConfirmTokenChecker.tsx create mode 100644 src/core/client/account/routes/email/Confirm/Sorry.tsx create mode 100644 src/core/client/account/routes/email/Confirm/Success.tsx create mode 100644 src/core/client/account/routes/email/Confirm/index.ts create mode 100644 src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap create mode 100644 src/core/client/account/test/confirmEmail.spec.tsx diff --git a/src/core/client/account/routeConfig.tsx b/src/core/client/account/routeConfig.tsx index afe46ef44..8ac67e360 100644 --- a/src/core/client/account/routeConfig.tsx +++ b/src/core/client/account/routeConfig.tsx @@ -1,6 +1,7 @@ import { makeRouteConfig, Route } from "found"; import React from "react"; +import ConfirmRoute from "./routes/email/Confirm"; import ResetRoute from "./routes/password/Reset"; export default makeRouteConfig( @@ -8,5 +9,8 @@ export default makeRouteConfig( + + + ); diff --git a/src/core/client/account/routes/email/Confirm/CheckConfirmTokenFetch.tsx b/src/core/client/account/routes/email/Confirm/CheckConfirmTokenFetch.tsx new file mode 100644 index 000000000..94596ccc2 --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/CheckConfirmTokenFetch.tsx @@ -0,0 +1,14 @@ +import { Environment } from "relay-runtime"; + +import { createFetch } from "coral-framework/lib/relay"; + +const CheckConfirmTokenFetch = createFetch( + "confirm", + async (environment: Environment, variables: { token: string }, { rest }) => + await rest.fetch("/account/confirm", { + method: "GET", + token: variables.token, + }) +); + +export default CheckConfirmTokenFetch; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmForm.tsx b/src/core/client/account/routes/email/Confirm/ConfirmForm.tsx new file mode 100644 index 000000000..2e8c44c4e --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/ConfirmForm.tsx @@ -0,0 +1,69 @@ +import { FORM_ERROR } from "final-form"; +import { Localized } from "fluent-react/compat"; +import React, { useCallback } from "react"; +import { Form } from "react-final-form"; + +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { useMutation } from "coral-framework/lib/relay"; +import { Button, HorizontalGutter, Typography } from "coral-ui/components"; + +import ConfirmMutation from "./ConfirmMutation"; + +interface Props { + token: string; + disabled?: boolean; + onSuccess: () => void; +} + +const ConfirmForm: React.FunctionComponent = ({ onSuccess, token }) => { + const confirm = useMutation(ConfirmMutation); + const onSubmit = useCallback(async () => { + try { + await confirm({ token }); + onSuccess(); + } catch (error) { + if (error instanceof InvalidRequestError) { + return error.invalidArgs; + } + return { [FORM_ERROR]: error.message }; + } + return; + }, [token]); + return ( +
+
+ {({ handleSubmit, submitting }) => ( + + + + + Email Confirmation + + + + Click below to confirm your email address. + + + + + + + + + +
+ )} + +
+ ); +}; + +export default ConfirmForm; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmMutation.tsx b/src/core/client/account/routes/email/Confirm/ConfirmMutation.tsx new file mode 100644 index 000000000..bb79c867b --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/ConfirmMutation.tsx @@ -0,0 +1,14 @@ +import { Environment } from "relay-runtime"; + +import { createMutation } from "coral-framework/lib/relay"; + +const ConfirmMutation = createMutation( + "confirm", + async (environment: Environment, variables: { token: string }, { rest }) => + await rest.fetch("/account/confirm", { + method: "PUT", + token: variables.token, + }) +); + +export default ConfirmMutation; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx b/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx new file mode 100644 index 000000000..d7796118c --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx @@ -0,0 +1,33 @@ +import React, { useCallback, useState } from "react"; + +import { withRouteConfig } from "coral-framework/lib/router"; +import { parseHashQuery } from "coral-framework/utils"; + +import ConfirmForm from "./ConfirmForm"; +import ConfirmTokenChecker from "./ConfirmTokenChecker"; +import Success from "./Success"; + +interface Props { + token: string | undefined; +} + +const ConfirmRoute: React.FunctionComponent = ({ token }) => { + const [suceeded, setSuceeded] = useState(false); + const onSuccess = useCallback(() => { + setSuceeded(true); + }, []); + return ( + + {!suceeded && } + {suceeded && } + + ); +}; + +const enhanced = withRouteConfig({ + render: ({ match, Component }) => ( + + ), +})(ConfirmRoute); + +export default enhanced; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmTokenChecker.tsx b/src/core/client/account/routes/email/Confirm/ConfirmTokenChecker.tsx new file mode 100644 index 000000000..6bb1b581f --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/ConfirmTokenChecker.tsx @@ -0,0 +1,93 @@ +import { ERROR_CODES } from "coral-common/errors"; +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { useFetch } from "coral-framework/lib/relay"; +import { Delay, Flex, Spinner } from "coral-ui/components"; +import { Localized } from "fluent-react/compat"; +import React, { useEffect, useState } from "react"; + +import CheckConfirmTokenFetch from "./CheckConfirmTokenFetch"; +import Sorry from "./Sorry"; + +interface Props { + token: string | undefined; +} + +type TokenState = + | "VALID" + | "INVALID" + | "EXPIRED" + | "MISSING" + | "RATE_LIMIT_EXCEEDED" + | "UNKNOWN" + | "UNCHECKED"; + +const ConfirmTokenChecker: React.FunctionComponent = ({ + token, + children, +}) => { + const checkConfirmToken = useFetch(CheckConfirmTokenFetch); + const [tokenState, setTokenState] = useState("UNCHECKED"); + const [reason, setReason] = useState(""); + useEffect(() => { + if (token) { + async function setAndCheckToken() { + try { + await checkConfirmToken({ token: token! }); + setTokenState("VALID"); + } catch (e) { + setReason(e.message); + if (e instanceof InvalidRequestError) { + switch (e.code) { + case ERROR_CODES.RATE_LIMIT_EXCEEDED: + setTokenState("RATE_LIMIT_EXCEEDED"); + return; + case ERROR_CODES.EMAIL_CONFIRM_TOKEN_EXPIRED: + setTokenState("EXPIRED"); + return; + case ERROR_CODES.INTEGRATION_DISABLED: + case ERROR_CODES.USER_NOT_FOUND: + case ERROR_CODES.TOKEN_INVALID: + setTokenState("INVALID"); + return; + default: + setTokenState("UNKNOWN"); + return; + } + } + setTokenState("UNKNOWN"); + } + } + setAndCheckToken(); + } else { + setTokenState("MISSING"); + } + return; + }, [token]); + + switch (tokenState) { + case "VALID": + return <>{children}; + case "UNCHECKED": + return ( + + + + + + ); + case "MISSING": + return ( + + The Confirm Token seems to be missing. + + } + /> + ); + default: + return ; + } +}; + +export default ConfirmTokenChecker; diff --git a/src/core/client/account/routes/email/Confirm/Sorry.tsx b/src/core/client/account/routes/email/Confirm/Sorry.tsx new file mode 100644 index 000000000..fc276f79e --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/Sorry.tsx @@ -0,0 +1,23 @@ +import { Localized } from "fluent-react/compat"; +import React from "react"; + +import { CallOut, HorizontalGutter, Typography } from "coral-ui/components"; + +interface Props { + reason: React.ReactNode; +} + +const Sorry: React.FunctionComponent = ({ reason }) => { + return ( + + + Oops Sorry! + + + {reason} + + + ); +}; + +export default Sorry; diff --git a/src/core/client/account/routes/email/Confirm/Success.tsx b/src/core/client/account/routes/email/Confirm/Success.tsx new file mode 100644 index 000000000..3672bab36 --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/Success.tsx @@ -0,0 +1,21 @@ +import { Localized } from "fluent-react/compat"; +import React from "react"; + +import { HorizontalGutter, Typography } from "coral-ui/components"; + +const Success: React.FunctionComponent = () => { + return ( + + + Email successfully confirmed + + + + You may now close this window. + + + + ); +}; + +export default Success; diff --git a/src/core/client/account/routes/email/Confirm/index.ts b/src/core/client/account/routes/email/Confirm/index.ts new file mode 100644 index 000000000..4a8e68ac6 --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/index.ts @@ -0,0 +1 @@ +export { default, default as ConfirmRoute } from "./ConfirmRoute"; diff --git a/src/core/client/account/routes/password/Reset/Success.tsx b/src/core/client/account/routes/password/Reset/Success.tsx index 40f9d1397..0498501f0 100644 --- a/src/core/client/account/routes/password/Reset/Success.tsx +++ b/src/core/client/account/routes/password/Reset/Success.tsx @@ -3,7 +3,7 @@ import React from "react"; import { HorizontalGutter, Typography } from "coral-ui/components"; -const Sorry: React.FunctionComponent = () => { +const Success: React.FunctionComponent = () => { return ( @@ -19,4 +19,4 @@ const Sorry: React.FunctionComponent = () => { ); }; -export default Sorry; +export default Success; diff --git a/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap b/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap new file mode 100644 index 000000000..f877b4620 --- /dev/null +++ b/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders form 1`] = ` +
+
+
+
+
+
+
+

+ Email Confirmation +

+

+ Click below to confirm your email address. +

+
+
+ +
+
+
+
+
+
+`; + +exports[`renders missing confirm token 1`] = ` +
+
+
+
+

+ Oops Sorry! +

+
+ + The Confirm Token seems to be missing. + +
+
+
+
+`; diff --git a/src/core/client/account/test/confirmEmail.spec.tsx b/src/core/client/account/test/confirmEmail.spec.tsx new file mode 100644 index 000000000..5e0fc1354 --- /dev/null +++ b/src/core/client/account/test/confirmEmail.spec.tsx @@ -0,0 +1,151 @@ +import sinon from "sinon"; + +import { GQLResolver } from "coral-framework/schema"; +import { + act, + createAccessToken, + CreateTestRendererParams, + replaceHistoryLocation, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import { ERROR_CODES } from "coral-common/errors"; +import { InvalidRequestError } from "coral-framework/lib/errors"; +import create from "./create"; + +const token = createAccessToken(); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context } = create(); + return { + context, + testRenderer, + root: testRenderer.root, + }; +} + +it("renders missing confirm token", async () => { + replaceHistoryLocation("http://localhost/account/email/confirm"); + const { root } = await createTestRenderer(); + await waitForElement(() => + within(root).getByText("The Confirm Token seems to be missing", { + exact: false, + }) + ); + expect(within(root).toJSON()).toMatchSnapshot(); +}); + +it("renders form", async () => { + replaceHistoryLocation( + `http://localhost/account/email/confirm#confirmToken=${token}` + ); + const { root, context } = await createTestRenderer(); + + const restMock = sinon.mock(context.rest); + restMock + .expects("fetch") + .withArgs("/account/confirm", { + method: "GET", + token, + }) + .once(); + + await act(async () => { + await waitForElement(() => + within(root).getByText("Email Confirmation", { + exact: false, + }) + ); + }); + expect(within(root).toJSON()).toMatchSnapshot(); + restMock.verify(); +}); + +it("renders error from server", async () => { + replaceHistoryLocation( + `http://localhost/account/email/confirm#confirmToken=${token}` + ); + + const codes = [ + ERROR_CODES.RATE_LIMIT_EXCEEDED, + ERROR_CODES.EMAIL_CONFIRM_TOKEN_EXPIRED, + ERROR_CODES.INTEGRATION_DISABLED, + ERROR_CODES.USER_NOT_FOUND, + ERROR_CODES.TOKEN_INVALID, + ]; + + for (const code of codes) { + const { root, context } = await createTestRenderer(); + + const restMock = sinon.mock(context.rest); + restMock + .expects("fetch") + .withArgs("/account/confirm", { + method: "GET", + token, + }) + .once() + .throwsException( + new InvalidRequestError({ + code, + }) + ); + + await act(async () => { + await waitForElement(() => + within(root).getByText(code, { + exact: false, + }) + ); + }); + restMock.verify(); + } +}); + +it("submits form", async () => { + replaceHistoryLocation( + `http://localhost/account/email/confirm#confirmToken=${token}` + ); + const { root, context } = await createTestRenderer(); + + const restMock = sinon.mock(context.rest); + restMock + .expects("fetch") + .withArgs("/account/confirm", { + method: "GET", + token, + }) + .once(); + + restMock + .expects("fetch") + .withArgs("/account/confirm", { + method: "PUT", + token, + }) + .once(); + + await act(async () => { + await waitForElement(() => + within(root).getByText("Email Confirmation", { + exact: false, + }) + ); + }); + const form = within(root).getByType("form"); + + // Submit valid form. + await act(async () => { + form.props.onSubmit(); + await waitForElement(() => + within(root).getByText("successfully", { + exact: false, + }) + ); + }); + + restMock.verify(); +}); diff --git a/src/locales/en-US/account.ftl b/src/locales/en-US/account.ftl index 9dc218e43..2f6ff16ca 100644 --- a/src/locales/en-US/account.ftl +++ b/src/locales/en-US/account.ftl @@ -16,3 +16,14 @@ resetPassword-youMayClose = You may now close this window and sign in to your account with your new password. resetPassword-oopsSorry = Oops Sorry! resetPassword-missingResetToken = The Reset Token seems to be missing. + +## Email Confirmation + +confirmEmail-emailConfirmation = Email Confirmation +confirmEmail-confirmEmail = Confirm email +confirmEmail-pleaseClickToConfirm = Click below to confirm your email address. +confirmEmail-oopsSorry = Oops Sorry! +confirmEmail-missingConfirmToken = The Confirm Token seems to be missing. +confirmEmail-successfullyConfirmed = Email successfully confirmed +confirmEmail-youMayClose = + You may now close this window.