mirror of
https://github.com/wassname/talk.git
synced 2026-07-04 13:06:17 +08:00
feat: added email confirmation UI (#2358)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user