feat: added email confirmation UI (#2358)

This commit is contained in:
Wyatt Johnson
2019-06-13 21:37:51 +00:00
committed by Vinh
parent 5311681333
commit f8cf34e34d
13 changed files with 522 additions and 2 deletions
+4
View File
@@ -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(
<Route path="password">
<Route path="reset" {...ResetRoute.routeConfig} />
</Route>
<Route path="email">
<Route path="confirm" {...ConfirmRoute.routeConfig} />
</Route>
</Route>
);
@@ -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<void>("/account/confirm", {
method: "GET",
token: variables.token,
})
);
export default CheckConfirmTokenFetch;
@@ -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<Props> = ({ 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 (
<div>
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized id="confirmEmail-emailConfirmation">
<Typography variant="heading1">Email Confirmation</Typography>
</Localized>
<Localized id="confirmEmail-pleaseClickToConfirm">
<Typography variant="bodyCopy">
Click below to confirm your email address.
</Typography>
</Localized>
</HorizontalGutter>
<HorizontalGutter>
<Localized id="confirmEmail-confirmEmail">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitting}
fullWidth
>
Confirm email
</Button>
</Localized>
</HorizontalGutter>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default ConfirmForm;
@@ -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<void>("/account/confirm", {
method: "PUT",
token: variables.token,
})
);
export default ConfirmMutation;
@@ -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<Props> = ({ token }) => {
const [suceeded, setSuceeded] = useState<boolean>(false);
const onSuccess = useCallback(() => {
setSuceeded(true);
}, []);
return (
<ConfirmTokenChecker token={token}>
{!suceeded && <ConfirmForm token={token!} onSuccess={onSuccess} />}
{suceeded && <Success />}
</ConfirmTokenChecker>
);
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).confirmToken} />
),
})(ConfirmRoute);
export default enhanced;
@@ -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<Props> = ({
token,
children,
}) => {
const checkConfirmToken = useFetch(CheckConfirmTokenFetch);
const [tokenState, setTokenState] = useState<TokenState>("UNCHECKED");
const [reason, setReason] = useState<string>("");
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 (
<Flex justifyContent="center">
<Delay>
<Spinner />
</Delay>
</Flex>
);
case "MISSING":
return (
<Sorry
reason={
<Localized id="confirmEmail-missingConfirmToken">
<span>The Confirm Token seems to be missing.</span>
</Localized>
}
/>
);
default:
return <Sorry reason={reason} />;
}
};
export default ConfirmTokenChecker;
@@ -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<Props> = ({ reason }) => {
return (
<HorizontalGutter size="double">
<Localized id="confirmEmail-oopsSorry">
<Typography variant="heading1">Oops Sorry!</Typography>
</Localized>
<CallOut color="error" fullWidth>
{reason}
</CallOut>
</HorizontalGutter>
);
};
export default Sorry;
@@ -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 (
<HorizontalGutter size="double">
<Localized id="confirmEmail-successfullyConfirmed">
<Typography variant="heading1">Email successfully confirmed</Typography>
</Localized>
<Localized id="confirmEmail-youMayClose">
<Typography variant="bodyCopy">
You may now close this window.
</Typography>
</Localized>
</HorizontalGutter>
);
};
export default Success;
@@ -0,0 +1 @@
export { default, default as ConfirmRoute } from "./ConfirmRoute";
@@ -3,7 +3,7 @@ import React from "react";
import { HorizontalGutter, Typography } from "coral-ui/components";
const Sorry: React.FunctionComponent = () => {
const Success: React.FunctionComponent = () => {
return (
<HorizontalGutter size="double">
<Localized id="resetPassword-successfullyReset">
@@ -19,4 +19,4 @@ const Sorry: React.FunctionComponent = () => {
);
};
export default Sorry;
export default Success;
@@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders form 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Email Confirmation
</h1>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Click below to confirm your email address.
</p>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Confirm email
</button>
</div>
</div>
</form>
</div>
</div>
</div>
`;
exports[`renders missing confirm token 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
>
<span>
The Confirm Token seems to be missing.
</span>
</div>
</div>
</div>
</div>
`;
@@ -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<GQLResolver> = {}
) {
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();
});
+11
View File
@@ -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.