[CORL-845] Account Linking (#2818)

* feat: added new linking backend

* feat: added duplicateEmail to hash

* fix: stored the duplicate email on the user

* feat: initial implmentation of account linking in auth

* test: fix unit tests

* fix+test: translations and tests added

* chore+test: rename view to LINK_ACCOUNT + more tests

* feat+test: account linking admin + more tests

* feat: Handle incomplete accounts

* chore: add some comments

* feat: expose duplicateEmail through graphql and impl for stream

* feat: admin to use duplicateEmail from graphql

* fix: no need to validate password for account linking

* fix: dont validate password

* fix: no need to render error message when account was incomplete

* chore: log to console when encountering incomplete account

* chore: adjust comment

* chore: simplify + add comments

* chore: wording

* chore: comments

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Wyatt Johnson
2020-02-25 15:46:32 -05:00
committed by GitHub
co-authored by Vinh Kim Gardner
parent 7497e33046
commit dcb2a10e72
80 changed files with 2414 additions and 710 deletions
@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`get access token from url 1`] = `
"{
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"accessToken\\": \\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==\\",
\\"accessTokenExp\\": null,
\\"accessTokenJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
\\"redirectPath\\": null,
\\"authView\\": \\"SIGN_IN\\",
\\"authError\\": null
}"
`;
exports[`init local state 1`] = `
"{
\\"client:root\\": {
\\"__id\\": \\"client:root\\",
\\"__typename\\": \\"__Root\\",
\\"local\\": {
\\"__ref\\": \\"client:root.local\\"
}
},
\\"client:root.local\\": {
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"accessToken\\": \\"\\",
\\"accessTokenExp\\": null,
\\"accessTokenJTI\\": null,
\\"redirectPath\\": null,
\\"authView\\": \\"SIGN_IN\\",
\\"authError\\": null
}
}"
`;
@@ -0,0 +1,49 @@
import { Environment, RecordSource } from "relay-runtime";
import { LOCAL_ID } from "coral-framework/lib/relay";
import {
createAccessToken,
createRelayEnvironment,
replaceHistoryLocation,
} from "coral-framework/testHelpers";
import initLocalState from "./initLocalState";
let environment: Environment;
let source: RecordSource;
const context = {
localStorage: window.localStorage,
sessionStorage: window.sessionStorage,
};
beforeEach(() => {
source = new RecordSource();
environment = createRelayEnvironment({
source,
initLocalState: false,
});
});
it("init local state", async () => {
await initLocalState(environment, context as any);
expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot();
});
it("get access token from url", async () => {
const restoreHistoryLocation = replaceHistoryLocation(
`http://localhost/#accessToken=${createAccessToken()}`
);
await initLocalState(environment, context as any);
expect(JSON.stringify(source.get(LOCAL_ID), null, 2)).toMatchSnapshot();
restoreHistoryLocation();
});
it("get error from url", async () => {
const restoreHistoryLocation = replaceHistoryLocation(
`http://localhost/#error=error`
);
await initLocalState(environment, context as any);
expect(source.get(LOCAL_ID)!.authError).toBe("error");
restoreHistoryLocation();
});
@@ -8,6 +8,7 @@ enum View {
CREATE_USERNAME
CREATE_PASSWORD
ADD_EMAIL_ADDRESS
LINK_ACCOUNT
}
extend type Comment {
@@ -31,6 +32,8 @@ type Local {
redirectPath: String
authView: View
authError: String
# Duplicate email found when adding email during auth.
authDuplicateEmail: String
siteID: String
}
@@ -50,7 +50,7 @@ function createAuthCheckRoute(check: CheckParams) {
}
private shouldRedirectTo(props: Props = this.props) {
if (!props.data || props.data.viewer) {
if (!props.data || (props.data.viewer && props.data.viewer.email)) {
return false;
}
return true;
@@ -28,38 +28,58 @@ type Props = {
function handleAccountCompletion(props: Props) {
const {
local: { authView },
local: { authView, authDuplicateEmail },
viewer,
auth,
setAuthView,
} = props;
if (viewer) {
if (!viewer.email) {
if (authView !== "ADD_EMAIL_ADDRESS") {
// email not set yet.
if (
// duplicate email detected during the `ADD_EMAIL_ADDRESS` process.
authDuplicateEmail ||
// detected duplicate email usually coming from a social login.
viewer.duplicateEmail
) {
// Duplicate email detected.
if (authView !== "ADD_EMAIL_ADDRESS" && authView !== "LINK_ACCOUNT") {
// `ADD_EMAIL_ADDRESS` view is allowed in case the viewer wants to change the email address.
// otherwise direct to the link account view.
setAuthView({ view: "LINK_ACCOUNT" });
}
} else if (authView !== "ADD_EMAIL_ADDRESS") {
setAuthView({ view: "ADD_EMAIL_ADDRESS" });
}
} else if (!viewer.username) {
// username not set yet.
if (authView !== "CREATE_USERNAME") {
// direct to create username view.
setAuthView({ view: "CREATE_USERNAME" });
}
} else if (
// password not set when local auth is enabled.
!viewer.profiles.some(p => p.__typename === "LocalProfile") &&
auth.integrations.local.enabled &&
(auth.integrations.local.targetFilter.admin ||
auth.integrations.local.targetFilter.stream)
) {
if (authView !== "CREATE_PASSWORD") {
// direct to create password view.
setAuthView({ view: "CREATE_PASSWORD" });
}
} else {
// all set, complete account.
props
.completeAccount({ accessToken: props.local.accessToken! })
.then(() => {
props.router.replace(props.local.redirectPath || "/admin");
});
// account completed.
return true;
}
}
// account not completed yet.
return false;
}
@@ -102,6 +122,7 @@ const enhanced = withLocalStateContainer(
fragment AccountCompletionContainerLocal on Local {
accessToken
authView
authDuplicateEmail
redirectPath
}
`
@@ -124,6 +145,7 @@ const enhanced = withLocalStateContainer(
fragment AccountCompletionContainer_viewer on User {
username
email
duplicateEmail
profiles {
__typename
}
@@ -9,7 +9,11 @@ interface Props {
children: React.ReactNode;
}
const CompleteAccountBox: FunctionComponent<Props> = ({ title, children }) => {
const CompleteAccountBox: FunctionComponent<Props> = ({
title,
children,
...rest
}) => {
return (
<div data-testid="completeAccountBox">
<Flex justifyContent="center">
@@ -27,7 +31,9 @@ const CompleteAccountBox: FunctionComponent<Props> = ({ title, children }) => {
{title}
</Typography>
</div>
<div className={styles.main}>{children}</div>
<div className={styles.main}>
<div {...rest}>{children}</div>
</div>
</div>
</Flex>
</div>
+14 -5
View File
@@ -2,9 +2,10 @@ import React, { FunctionComponent } from "react";
import { PropTypesOf } from "coral-framework/types";
import AddEmailAddressContainer from "./views/AddEmailAddress";
import AddEmailAddress from "./views/AddEmailAddress";
import CreatePasswordContainer from "./views/CreatePassword";
import CreateUsernameContainer from "./views/CreateUsername";
import LinkAccountContainer from "./views/LinkAccount";
import SignInContainer from "./views/SignIn";
export type View =
@@ -14,14 +15,20 @@ export type View =
| "CREATE_USERNAME"
| "CREATE_PASSWORD"
| "ADD_EMAIL_ADDRESS"
| "LINK_ACCOUNT"
| "%future added value";
interface Props {
view: View;
auth: PropTypesOf<typeof SignInContainer>["auth"];
viewer: PropTypesOf<typeof LinkAccountContainer>["viewer"];
}
const renderView = (view: Props["view"], auth: Props["auth"]) => {
const renderView = (
view: Props["view"],
auth: Props["auth"],
viewer: Props["viewer"]
) => {
switch (view) {
case "SIGN_IN":
return <SignInContainer auth={auth} />;
@@ -30,14 +37,16 @@ const renderView = (view: Props["view"], auth: Props["auth"]) => {
case "CREATE_PASSWORD":
return <CreatePasswordContainer />;
case "ADD_EMAIL_ADDRESS":
return <AddEmailAddressContainer />;
return <AddEmailAddress />;
case "LINK_ACCOUNT":
return <LinkAccountContainer viewer={viewer} />;
default:
throw new Error(`Unknown view ${view}`);
}
};
const Login: FunctionComponent<Props> = ({ view, auth }) => (
<div>{renderView(view, auth)}</div>
const Login: FunctionComponent<Props> = ({ view, auth, viewer }) => (
<div>{renderView(view, auth, viewer)}</div>
);
export default Login;
@@ -32,6 +32,7 @@ class LoginRoute extends Component<Props> {
<Login
auth={this.props.data.settings.auth}
view={this.props.local.authView!}
viewer={this.props.data.viewer}
/>
</AccountCompletionContainer>
);
@@ -43,6 +44,7 @@ const enhanced = withRouteConfig<LoginRouteQueryResponse>({
query LoginRouteQuery {
viewer {
...AccountCompletionContainer_viewer
...LinkAccountContainer_viewer
}
settings {
auth {
@@ -4,7 +4,12 @@ import { createMutation, LOCAL_ID } from "coral-framework/lib/relay";
export interface SetAuthViewInput {
// TODO: replace with generated typescript types.
view: "SIGN_IN" | "ADD_EMAIL_ADDRESS" | "CREATE_USERNAME" | "CREATE_PASSWORD";
view:
| "SIGN_IN"
| "ADD_EMAIL_ADDRESS"
| "CREATE_USERNAME"
| "CREATE_PASSWORD"
| "LINK_ACCOUNT";
}
const SetAuthViewMutation = createMutation(
@@ -0,0 +1,23 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { createMutation, LOCAL_ID } from "coral-framework/lib/relay";
export interface SetDuplicateEmailInput {
duplicateEmail: string | null;
}
/**
* SetDuplicateEmailMutation is used to set the duplicateEmail in localState.
* It is used in the `LINK_ACCOUNT` view.
*/
const SetDuplicateEmailMutation = createMutation(
"setDuplicateEmail",
async (environment: Environment, input: SetDuplicateEmailInput) => {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.duplicateEmail, "authDuplicateEmail");
});
}
);
export default SetDuplicateEmailMutation;
@@ -32,7 +32,9 @@ exports[`renders correctly 1`] = `
<div
className="CompleteAccountBox-main"
>
content
<div>
content
</div>
</div>
</div>
</ForwardRef(forwardRef)>
@@ -1,8 +1,10 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { FORM_ERROR } from "final-form";
import React, { FunctionComponent, useCallback } from "react";
import { Form } from "react-final-form";
import { FormError, OnSubmit } from "coral-framework/lib/form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
CallOut,
@@ -11,29 +13,45 @@ import {
} from "coral-ui/components";
import CompleteAccountBox from "../../CompleteAccountBox";
import SetAuthViewMutation from "../../SetAuthViewMutation";
import SetDuplicateEmailMutation from "../../SetDuplicateEmailMutation";
import ConfirmEmailField from "./ConfirmEmailField";
import EmailField from "./EmailField";
import SetEmailMutation from "./SetEmailMutation";
interface FormProps {
email: string;
}
interface FormSubmitProps extends FormProps, FormError {}
export interface AddEmailAddressForm {
onSubmit: OnSubmit<FormSubmitProps>;
}
const AddEmailAddress: FunctionComponent<AddEmailAddressForm> = props => {
const AddEmailAddress: FunctionComponent = () => {
const setDuplicateEmail = useMutation(SetDuplicateEmailMutation);
const setEmail = useMutation(SetEmailMutation);
const setView = useMutation(SetAuthViewMutation);
const onSubmit = useCallback(
async (input: any) => {
try {
await setEmail({ email: input.email });
return;
} catch (error) {
if (error instanceof InvalidRequestError) {
if (error.code === "DUPLICATE_EMAIL") {
setDuplicateEmail({ duplicateEmail: input.email });
setView({ view: "LINK_ACCOUNT" });
return;
}
return error.invalidArgs;
}
return { [FORM_ERROR]: error.message };
}
},
[setEmail]
);
return (
<CompleteAccountBox
data-testid="addEmailAddress-container"
title={
<Localized id="addEmailAddress-addEmailAddressHeader">
<span>Add Email Address</span>
</Localized>
}
>
<Form onSubmit={props.onSubmit}>
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="oneAndAHalf">
@@ -1,33 +0,0 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import { MutationProp, withMutation } from "coral-framework/lib/relay";
import { PropTypesOf } from "coral-framework/types";
import AddEmailAddress from "./AddEmailAddress";
import SetEmailMutation from "./SetEmailMutation";
interface Props {
setEmail: MutationProp<typeof SetEmailMutation>;
}
class AddEmailAddressContainer extends Component<Props> {
private handleSubmit: PropTypesOf<
typeof AddEmailAddress
>["onSubmit"] = async (input, form) => {
try {
await this.props.setEmail({ email: input.email });
return;
} catch (error) {
return { [FORM_ERROR]: error.message };
}
};
public render() {
// eslint-disable-next-line:no-empty
return <AddEmailAddress onSubmit={this.handleSubmit} />;
}
}
const enhanced = withMutation(SetEmailMutation)(AddEmailAddressContainer);
export default enhanced;
@@ -1,4 +1 @@
export {
default,
default as AddEmailAddressContainer,
} from "./AddEmailAddressContainer";
export { default, default as AddEmailAddress } from "./AddEmailAddress";
@@ -0,0 +1,16 @@
.root {
position: relative;
}
.hr {
position: absolute;
border: 0;
border-top: 1px solid var(--palette-divider);
width: 100%;
margin: 0;
}
.text {
composes: heading3 from "coral-ui/shared/typography.css";
position: relative;
background-color: var(--palette-common-white);
padding: 0 var(--mini-unit);
}
@@ -0,0 +1,10 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import HorizontalSeparator from "./HorizontalSeparator";
it("renders correctly", () => {
const renderer = createRenderer();
renderer.render(<HorizontalSeparator>Or</HorizontalSeparator>);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -0,0 +1,18 @@
import React, { FunctionComponent } from "react";
import { Flex } from "coral-ui/components";
import styles from "./HorizontalSeparator.css";
interface Props {
children: string;
}
const HorizontalSeparator: FunctionComponent<Props> = props => (
<Flex className={styles.root} alignItems="center" justifyContent="center">
<hr className={styles.hr} />
<div className={styles.text}>{props.children}</div>
</Flex>
);
export default HorizontalSeparator;
@@ -0,0 +1,172 @@
import { Localized } from "@fluent/react/compat";
import { FORM_ERROR } from "final-form";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import {
colorFromMeta,
FormError,
OnSubmit,
ValidationMessage,
} from "coral-framework/lib/form";
import {
graphql,
useLocal,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import { required } from "coral-framework/lib/validation";
import {
Button,
CallOut,
FormField,
HorizontalGutter,
InputLabel,
PasswordField,
Typography,
} from "coral-ui/components";
import { LinkAccountContainer_viewer } from "coral-admin/__generated__/LinkAccountContainer_viewer.graphql";
import { LinkAccountContainerLocal } from "coral-admin/__generated__/LinkAccountContainerLocal.graphql";
import CompleteAccountBox from "../../CompleteAccountBox";
import SetAuthViewMutation from "../../SetAuthViewMutation";
import LinkAccountMutation from "./LinkAccountMutation";
import OrSeparator from "./OrSeparator";
interface FormProps {
password: string;
}
interface FormErrorProps extends FormProps, FormError {}
interface Props {
viewer: LinkAccountContainer_viewer | null;
}
const LinkAccountContainer: FunctionComponent<Props> = props => {
const [local] = useLocal<LinkAccountContainerLocal>(graphql`
fragment LinkAccountContainerLocal on Local {
authDuplicateEmail
}
`);
const setView = useMutation(SetAuthViewMutation);
const linkAccount = useMutation(LinkAccountMutation);
const duplicateEmail =
local.authDuplicateEmail || (props.viewer && props.viewer.duplicateEmail);
const onSubmit: OnSubmit<FormErrorProps> = useCallback(
async (input, form) => {
if (!duplicateEmail) {
return { [FORM_ERROR]: "duplicate email not set" };
}
try {
await linkAccount({
email: duplicateEmail,
password: input.password,
});
return;
} catch (error) {
return { [FORM_ERROR]: error.message };
}
},
[linkAccount]
);
const changeEmail = useCallback(() => {
setView({ view: "ADD_EMAIL_ADDRESS" });
}, [setView]);
return (
<CompleteAccountBox
data-testid="linkAccount-container"
title={
<Localized id="linkAccount-linkAccountHeader">
<span>Link Account</span>
</Localized>
}
>
<HorizontalGutter spacing={3}>
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="oneAndAHalf">
<Localized
id="linkAccount-alreadyAssociated"
$email={duplicateEmail}
strong={<strong />}
>
<Typography variant="bodyCopy">
The email <strong>{duplicateEmail}</strong> is already
associated with an account. If you would like to link these
enter your password.
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field name="password" validate={required}>
{({ input, meta }) => (
<FormField>
<Localized id="linkAccount-passwordLabel">
<InputLabel htmlFor={input.name}>Password</InputLabel>
</Localized>
<Localized
id="linkAccount-passwordTextField"
attrs={{ placeholder: true }}
>
<PasswordField
{...input}
id={input.name}
placeholder="Password"
color={colorFromMeta(meta)}
disabled={submitting}
fullWidth
/>
</Localized>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<Localized id="linkAccount-linkAccountButton">
<Button
variant="filled"
color="primary"
size="large"
type="submit"
fullWidth
disabled={submitting}
>
Link Account
</Button>
</Localized>
</HorizontalGutter>
</form>
)}
</Form>
<OrSeparator />
<Localized id="linkAccount-useDifferentEmail">
<Button
variant="filled"
size="large"
type="submit"
fullWidth
onClick={changeEmail}
>
Use a different email address
</Button>
</Localized>
</HorizontalGutter>
</CompleteAccountBox>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment LinkAccountContainer_viewer on User {
duplicateEmail
}
`,
})(LinkAccountContainer);
export default enhanced;
@@ -0,0 +1,16 @@
import { pick } from "lodash";
import { createMutation } from "coral-framework/lib/relay";
import { linkAccount, LinkAccountInput } from "coral-framework/rest";
export type LinkAccountMutation = (input: LinkAccountInput) => Promise<void>;
const LinkAccountMutation = createMutation(
"linkAccount",
async (_, input: LinkAccountInput, { rest, clearSession }) => {
const result = await linkAccount(rest, pick(input, ["email", "password"]));
await clearSession(result.token);
}
);
export default LinkAccountMutation;
@@ -0,0 +1,12 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import HorizontalSeparator from "./HorizontalSeparator";
const OrSeparator: FunctionComponent = () => (
<Localized id="login-orSeparator">
<HorizontalSeparator>Or</HorizontalSeparator>
</Localized>
);
export default OrSeparator;
@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<ForwardRef(forwardRef)
alignItems="center"
className="HorizontalSeparator-root"
justifyContent="center"
>
<hr
className="HorizontalSeparator-hr"
/>
<div
className="HorizontalSeparator-text"
>
Or
</div>
</ForwardRef(forwardRef)>
`;
@@ -0,0 +1,4 @@
export {
default,
default as LinkAccountContainer,
} from "./LinkAccountContainer";
@@ -4,7 +4,7 @@ import React, { FunctionComponent } from "react";
import HorizontalSeparator from "./HorizontalSeparator";
const OrSeparator: FunctionComponent = () => (
<Localized id="login-signIn-orSeparator">
<Localized id="login-orSeparator">
<HorizontalSeparator>Or</HorizontalSeparator>
</Localized>
);
@@ -235,86 +235,90 @@ exports[`renders addEmailAddress view 1`] = `
<div
className="CompleteAccountBox-main"
>
<form
autoComplete="off"
onSubmit={[Function]}
<div
data-testid="addEmailAddress-container"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
<form
autoComplete="off"
onSubmit={[Function]}
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
For your added security, we require users to add an email address to their accounts.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="email"
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Email Address
</label>
For your added security, we require users to add an email address to their accounts.
</p>
<div
className="TextField-root TextField-fullWidth"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Email Address"
type="text"
value=""
/>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="email"
>
Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Email Address"
type="text"
value=""
/>
</div>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="confirmEmail"
>
Confirm Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="confirmEmail"
name="confirmEmail"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Confirm Email Address"
type="text"
value=""
/>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="confirmEmail"
>
Confirm Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="confirmEmail"
name="confirmEmail"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Confirm Email Address"
type="text"
value=""
/>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Add Email Address
</button>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Add Email Address
</button>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
@@ -130,90 +130,92 @@ exports[`renders createPassword view 1`] = `
<div
className="CompleteAccountBox-main"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
<div>
<form
autoComplete="off"
onSubmit={[Function]}
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
To protect against unauthorized changes to your account,
we require users to create a password.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Must be at least 8 characters
To protect against unauthorized changes to your account,
we require users to create a password.
</p>
<div
className="PasswordField-fullWidth PasswordField-root"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
>
Must be at least 8 characters
</p>
<div
className="PasswordField-wrapper"
className="PasswordField-fullWidth PasswordField-root"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Show password"
className="PasswordField-wrapper"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Show password"
>
visibility
</i>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Password
</button>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Password
</button>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
@@ -105,65 +105,67 @@ exports[`renders createUsername view 1`] = `
<div
className="CompleteAccountBox-main"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
<div>
<form
autoComplete="off"
onSubmit={[Function]}
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="username"
>
Username
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
You may use “_” and “.” Spaces not permitted.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="TextField-root TextField-fullWidth"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Username"
type="text"
value=""
/>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="username"
>
Username
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
>
You may use “_” and “.” Spaces not permitted.
</p>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Username"
type="text"
value=""
/>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Username
</button>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Username
</button>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,155 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders link account view 1`] = `
<div>
<div
data-testid="completeAccountBox"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyCenter"
>
<div
className="CompleteAccountBox-container"
>
<div
className="CompleteAccountBox-header"
>
<h1
className="Box-root Typography-root Typography-heading3 Typography-colorTextLight Typography-alignCenter CompleteAccountBox-heading3"
>
Finish Setting Up Your Account
</h1>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextLight Typography-alignCenter"
>
<span>
Link Account
</span>
</h1>
</div>
<div
className="CompleteAccountBox-main"
>
<div
data-testid="linkAccount-container"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-3"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
The email
<strong>
my@email.com
</strong>
is
already associated with an account. If you would like to
link these enter your password.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide password"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Link Account
</button>
</div>
</form>
<div
className="Box-root Flex-root HorizontalSeparator-root Flex-flex Flex-justifyCenter Flex-alignCenter"
>
<hr
className="HorizontalSeparator-hr"
/>
<div
className="HorizontalSeparator-text"
>
Or
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorRegular Button-variantFilled Button-fullWidth"
data-color="regular"
data-variant="filled"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Use a different email address
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -11,7 +11,12 @@ import {
} from "coral-framework/testHelpers";
import create from "../create";
import { emptyModerationQueues, settings, users } from "../fixtures";
import {
emptyModerationQueues,
settings,
siteConnection,
users,
} from "../fixtures";
const viewer = users.admins[0];
@@ -20,45 +25,49 @@ async function createTestRenderer(
) {
replaceHistoryLocation("http://localhost/admin/login");
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () =>
pureMerge<typeof viewer>(viewer, {
email: "",
username: "",
profiles: [],
}),
moderationQueues: () => emptyModerationQueues,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("SIGN_IN", "authView");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return act(() => {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
sites: () => siteConnection,
settings: () => settings,
viewer: () =>
pureMerge<typeof viewer>(viewer, {
email: "",
username: "",
profiles: [],
}),
moderationQueues: () => emptyModerationQueues,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("SIGN_IN", "authView");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return {
context,
testRenderer,
root: testRenderer.root,
};
return {
context,
testRenderer,
};
});
}
it("renders addEmailAddress view", async () => {
const { root } = await createTestRenderer();
await waitForElement(() => within(root).queryByText("Add Email Address"));
const { testRenderer } = await createTestRenderer();
await waitForElement(() =>
within(testRenderer.root).queryByText("Add Email Address")
);
});
it("renders createUsername view", async () => {
const { root } = await createTestRenderer({
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
viewer: () =>
@@ -70,11 +79,13 @@ it("renders createUsername view", async () => {
},
}),
});
await waitForElement(() => within(root).queryByText("Create Username"));
await waitForElement(() =>
within(testRenderer.root).queryByText("Create Username")
);
});
it("renders createPassword view", async () => {
const { root } = await createTestRenderer({
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
@@ -88,7 +99,9 @@ it("renders createPassword view", async () => {
},
}),
});
await waitForElement(() => within(root).queryByText("Create Password"));
await waitForElement(() =>
within(testRenderer.root).queryByText("Create Password")
);
});
it("do not render createPassword view when local auth is disabled", async () => {
@@ -145,3 +158,24 @@ it("complete account", async () => {
);
});
});
it("renders account linking view", async () => {
const { testRenderer } = await createTestRenderer({
resolvers: {
Query: {
viewer: () =>
pureMerge<typeof viewer>(viewer, {
email: "",
username: "hans",
duplicateEmail: "my@email.com",
}),
},
},
});
await act(async () => {
within(testRenderer.root).debug();
await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
});
});
@@ -1,4 +1,8 @@
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { pureMerge } from "coral-common/utils";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { GQLResolver } from "coral-framework/schema";
import {
act,
@@ -229,3 +233,46 @@ it("successfully sets email", async () => {
expect(toJSON(form)).toMatchSnapshot();
expect(resolvers.Mutation!.setEmail!.called).toBe(true);
});
it("switch to link account", async () => {
const email = "hans@test.com";
const setEmail = sinon.stub().callsFake((_: any, data: any) => {
throw new InvalidRequestError({ code: ERROR_CODES.DUPLICATE_EMAIL });
});
const {
testRenderer,
form,
emailAddressField,
confirmEmailAddressField,
} = await createTestRenderer({
resolvers: {
Mutation: {
setEmail,
},
},
muteNetworkErrors: true,
});
const submitButton = form.find(
i => i.type === "button" && i.props.type === "submit"
);
act(() => emailAddressField.props.onChange({ target: { value: email } }));
act(() =>
confirmEmailAddressField.props.onChange({
target: { value: email },
})
);
act(() => {
form.props.onSubmit();
});
expect(emailAddressField.props.disabled).toBe(true);
expect(confirmEmailAddressField.props.disabled).toBe(true);
expect(submitButton.props.disabled).toBe(true);
await act(async () => {
await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
});
});
@@ -0,0 +1,156 @@
import sinon from "sinon";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createAccessToken,
createResolversStub,
CreateTestRendererParams,
replaceHistoryLocation,
wait,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "../create";
import { settings, users } from "../fixtures";
const viewer = pureMerge<typeof users["admins"][0]>(users.admins[0], {
email: "",
});
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
replaceHistoryLocation("http://localhost/admin/login");
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
viewer: () => viewer,
settings: () => settings,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("LINK_ACCOUNT", "authView");
localRecord.setValue("my@email.com", "authDuplicateEmail");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
const form = within(container).queryByType("form")!;
const passwordField = within(form).getByLabelText("Password");
return {
context,
testRenderer,
form,
container,
passwordField,
};
}
it("renders link account view", async () => {
const { testRenderer } = await createTestRenderer();
expect(testRenderer.toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
it("checks for required password", async () => {
const { form, container } = await createTestRenderer();
act(() => {
form.props.onSubmit();
});
within(container).getByText("This field is required", { exact: false });
});
it("performs account linking", async () => {
const { form, passwordField, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/auth/link", {
method: "POST",
body: {
email: "my@email.com",
password: "testtest",
},
})
.once()
.returns({
token: createAccessToken(),
});
act(() => {
passwordField.props.onChange("testtest");
});
act(() => {
form.props.onSubmit();
});
// Look for visual cues for form submission progress.
expect(passwordField.props.disabled).toBe(true);
await act(async () => {
await wait(() => expect(passwordField.props.disabled).toBe(false));
});
restMock.verify();
});
it("shows server error", async () => {
const { form, passwordField, context } = await createTestRenderer();
const error = new Error("Server Error");
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/auth/link", {
method: "POST",
body: {
email: "my@email.com",
password: "testtest",
},
})
.once()
.throws(error);
act(() => {
passwordField.props.onChange("testtest");
});
act(() => {
form.props.onSubmit();
});
// Look for visual cues for form submission progress.
expect(passwordField.props.disabled).toBe(true);
await act(async () => {
await wait(() => expect(passwordField.props.disabled).toBe(false));
});
within(form).getByText("Server Error", { exact: false });
restMock.verify();
});
it("change email view", async () => {
const { testRenderer, container } = await createTestRenderer();
const button = within(container).getByText("Use a different Email", {
exact: false,
});
act(() => {
button.props.onClick();
});
await act(async () => {
await waitForElement(() =>
within(testRenderer.root).getByTestID("addEmailAddress-container")
);
});
});
@@ -28,7 +28,7 @@ interface Props {
function handleAccountCompletion(props: Props) {
const {
local: { view, accessToken },
local: { view, accessToken, duplicateEmail },
viewer,
auth,
setView,
@@ -36,13 +36,28 @@ function handleAccountCompletion(props: Props) {
} = props;
if (viewer) {
if (!viewer.email) {
if (view !== "ADD_EMAIL_ADDRESS") {
// email not set yet.
if (
// duplicate email detected during the `ADD_EMAIL_ADDRESS` process.
duplicateEmail ||
// detected duplicate email usually coming from a social login.
viewer.duplicateEmail
) {
// duplicateEmail detected.
if (view !== "ADD_EMAIL_ADDRESS" && view !== "LINK_ACCOUNT") {
// `ADD_EMAIL_ADDRESS` view is allowed in case the viewer wants to change the email address.
// otherwise direct to the link account view.
setView({ view: "LINK_ACCOUNT" });
}
} else if (view !== "ADD_EMAIL_ADDRESS") {
setView({ view: "ADD_EMAIL_ADDRESS" });
}
return false;
}
if (!viewer.username) {
// username not set yet.
if (view !== "CREATE_USERNAME") {
// direct to create username view.
setView({ view: "CREATE_USERNAME" });
}
return false;
@@ -52,14 +67,20 @@ function handleAccountCompletion(props: Props) {
auth.integrations.local.enabled &&
auth.integrations.local.targetFilter.stream
) {
// password not set when local auth is enabled.
if (view !== "CREATE_PASSWORD") {
// direct to create password view.
setView({ view: "CREATE_PASSWORD" });
}
return false;
}
// all set, complete account.
completeAccount({ accessToken: accessToken! });
// account completed.
return true;
}
// account not completed yet.
return false;
}
@@ -99,6 +120,7 @@ const enhanced = withLocalStateContainer(
fragment AccountCompletionContainerLocal on Local {
accessToken
view
duplicateEmail
}
`
)(
@@ -119,6 +141,7 @@ const enhanced = withLocalStateContainer(
fragment AccountCompletionContainer_viewer on User {
username
email
duplicateEmail
profiles {
__typename
}
+8 -3
View File
@@ -5,7 +5,8 @@ import { PropTypesOf } from "coral-framework/types";
import AddEmailAddress from "../views/AddEmailAddress";
import CreatePassword from "../views/CreatePassword";
import CreateUsername from "../views/CreateUsername";
import ForgotPassword from "../views/ForgotPassword";
import ForgotPasswordContainer from "../views/ForgotPassword";
import LinkAccountContainer from "../views/LinkAccount";
import SignInContainer from "../views/SignIn";
import SignUpContainer from "../views/SignUp";
import ViewRouter from "./ViewRouter";
@@ -19,11 +20,13 @@ export type View =
| "CREATE_USERNAME"
| "CREATE_PASSWORD"
| "ADD_EMAIL_ADDRESS"
| "LINK_ACCOUNT"
| "%future added value";
export interface AppProps {
view: View;
viewer: PropTypesOf<typeof ForgotPassword>["viewer"];
viewer: PropTypesOf<typeof ForgotPasswordContainer>["viewer"] &
PropTypesOf<typeof LinkAccountContainer>["viewer"];
auth: PropTypesOf<typeof SignInContainer>["auth"] &
PropTypesOf<typeof SignUpContainer>["auth"];
}
@@ -35,13 +38,15 @@ const render = ({ view, auth, viewer }: AppProps) => {
case "SIGN_IN":
return <SignInContainer auth={auth} />;
case "FORGOT_PASSWORD":
return <ForgotPassword viewer={viewer} />;
return <ForgotPasswordContainer viewer={viewer} />;
case "CREATE_USERNAME":
return <CreateUsername />;
case "CREATE_PASSWORD":
return <CreatePassword />;
case "ADD_EMAIL_ADDRESS":
return <AddEmailAddress />;
case "LINK_ACCOUNT":
return <LinkAccountContainer viewer={viewer} />;
default:
throw new Error(`Unknown view ${view}`);
}
@@ -61,6 +61,7 @@ const enhanced = withLocalStateContainer(
fragment AppContainer_viewer on User {
...AccountCompletionContainer_viewer
...ForgotPasswordContainer_viewer
...LinkAccountContainer_viewer
}
`,
})(AppContainer)
@@ -14,6 +14,7 @@ let source: RecordSource;
const context = {
localStorage: window.localStorage,
sessionStorage: window.sessionStorage,
};
beforeEach(() => {
+5 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable prettier/prettier */
import { commitLocalUpdate, Environment } from "relay-runtime";
import { parseQuery } from "coral-common/utils";
@@ -12,7 +13,10 @@ export default async function initLocalState(
environment: Environment,
context: CoralContext
) {
const { error = null, accessToken = null } = getParamsFromHashAndClearIt();
const {
error = null,
accessToken = null,
} = getParamsFromHashAndClearIt();
await initLocalBaseState(environment, context, accessToken);
+3
View File
@@ -5,6 +5,7 @@ enum View {
CREATE_USERNAME
CREATE_PASSWORD
ADD_EMAIL_ADDRESS
LINK_ACCOUNT
}
type Local {
@@ -13,6 +14,8 @@ type Local {
accessTokenJTI: String
view: View!
error: String
# Duplicate email found when adding email.
duplicateEmail: String
}
extend type Query {
@@ -0,0 +1,23 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { createMutation, LOCAL_ID } from "coral-framework/lib/relay";
export interface SetDuplicateEmailInput {
duplicateEmail: string | null;
}
/**
* SetDuplicateEmailMutation is used to set the duplicateEmail in localState.
* It is used in the `LINK_ACCOUNT` view.
*/
const SetDuplicateEmailMutation = createMutation(
"setDuplicateEmail",
(environment: Environment, input: SetDuplicateEmailInput) => {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.duplicateEmail, "duplicateEmail");
});
}
);
export default SetDuplicateEmailMutation;
@@ -8,7 +8,8 @@ export type View =
| "FORGOT_PASSWORD"
| "ADD_EMAIL_ADDRESS"
| "CREATE_USERNAME"
| "CREATE_PASSWORD";
| "CREATE_PASSWORD"
| "LINK_ACCOUNT";
export interface SetViewInput {
// TODO: replace with generated typescript types.
+3
View File
@@ -1 +1,4 @@
export { default as SetViewMutation } from "./SetViewMutation";
export {
default as SetDuplicateEmailMutation,
} from "./SetDuplicateEmailMutation";
@@ -864,155 +864,3 @@ GraphQL request (4:3)
</div>
</form>
`;
exports[`successfully sets email 1`] = `
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
For your added security, we require users to add an email address to their accounts.
Your email address will be used to:
</p>
<ul
className="UnorderedList-root"
>
<li
className="ListItem-root"
>
<div
className="ListItem-leftCol"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
done
</i>
</div>
<div>
<div
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Receive updates regarding any changes to your account
(email address, username, password, etc.)
</div>
</div>
</li>
<li
className="ListItem-root"
>
<div
className="ListItem-leftCol"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
done
</i>
</div>
<div>
<div
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Allow you to download your comments.
</div>
</div>
</li>
<li
className="ListItem-root"
>
<div
className="ListItem-leftCol"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
done
</i>
</div>
<div>
<div
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Send comment notifications that you have chosen to receive.
</div>
</div>
</li>
</ul>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="email"
>
Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Email Address"
type="email"
value="hans@test.com"
/>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="confirmEmail"
>
Confirm Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="confirmEmail"
name="confirmEmail"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Confirm Email Address"
type="text"
value="hans@test.com"
/>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Add Email Address
</button>
</div>
</form>
`;
@@ -0,0 +1,141 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders link account view 1`] = `
<div>
<div>
<div
data-testid="linkAccount-container"
>
<div
className="Box-root Flex-root Bar-root Flex-flex Flex-justifyCenter Flex-alignCenter"
>
<header>
<h1
className="Box-root Typography-root Typography-heading2 Typography-colorTextPrimary Typography-alignCenter Title-root"
>
Link Account
</h1>
</header>
</div>
<main
className="Main-root"
data-testid="linkAccount-main"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-3"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
The email
<strong>
my@email.com
</strong>
is
already associated with an account. If you would like to
link these enter your password.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide password"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Link Account
</button>
</div>
</form>
<div
className="Box-root Flex-root HorizontalSeparator-root Flex-flex Flex-justifyCenter Flex-alignCenter"
>
<hr
className="HorizontalSeparator-hr"
/>
<div
className="HorizontalSeparator-text"
>
Or
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorRegular Button-variantFilled Button-fullWidth"
data-color="regular"
data-variant="filled"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Use a different email address
</button>
</div>
</main>
</div>
</div>
</div>
`;
@@ -1,9 +1,11 @@
import { get } from "lodash";
import sinon from "sinon";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
createAccessToken,
createResolversStub,
CreateTestRendererParams,
wait,
waitForElement,
within,
@@ -16,40 +18,30 @@ import mockWindow from "./mockWindow";
let windowMock: ReturnType<typeof mockWindow>;
const accessToken = createAccessToken();
const viewer = { id: "me", profiles: [] };
async function createTestRenderer(
customResolver: any = {},
options: { muteNetworkErrors?: boolean; logNetwork?: boolean } = {}
params: CreateTestRendererParams<GQLResolver> = {}
) {
const resolvers = {
...customResolver,
Query: {
...customResolver.Query,
settings: sinon
.stub()
.returns(pureMerge(settings, get(customResolver, "Query.settings"))),
viewer: sinon
.stub()
.returns(
pureMerge(
{ id: "me", profiles: [] },
get(customResolver, "Query.viewer")
)
),
},
};
const { testRenderer, context } = create({
// Set this to true, to see graphql responses.
logNetwork: options.logNetwork,
muteNetworkErrors: options.muteNetworkErrors,
resolvers,
initLocalState: localRecord => {
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () => viewer,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("CREATE_PASSWORD", "view");
localRecord.setValue(accessToken, "accessToken");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return {
context,
testRenderer,
@@ -74,9 +66,12 @@ it("renders addEmailAddress view", async () => {
it("renders createUsername view", async () => {
const { root } = await createTestRenderer({
Query: {
viewer: {
email: "hans@test.com",
resolvers: {
Query: {
viewer: () =>
pureMerge(viewer, {
email: "hans@test.com",
}),
},
},
});
@@ -87,10 +82,13 @@ it("renders createUsername view", async () => {
it("renders createPassword view", async () => {
const { root } = await createTestRenderer({
Query: {
viewer: {
email: "hans@test.com",
username: "hans",
resolvers: {
Query: {
viewer: () =>
pureMerge(viewer, {
email: "hans@test.com",
username: "hans",
}),
},
},
});
@@ -99,21 +97,43 @@ it("renders createPassword view", async () => {
);
});
it("renders account linking view", async () => {
const { root } = await createTestRenderer({
resolvers: {
Query: {
viewer: () =>
pureMerge(viewer, {
email: "",
username: "hans",
duplicateEmail: "my@email.com",
}),
},
},
});
await waitForElement(() => within(root).getByTestID("linkAccount-container"));
within(root).getByText("my@email.com", { exact: false });
});
it("do not render createPassword view when local auth is disabled", async () => {
await createTestRenderer({
Query: {
viewer: {
email: "hans@test.com",
username: "hans",
},
settings: {
auth: {
integrations: {
local: {
enabled: false,
resolvers: {
Query: {
viewer: () =>
pureMerge(viewer, {
email: "hans@test.com",
username: "hans",
}),
settings: () =>
pureMerge(settings, {
auth: {
integrations: {
local: {
enabled: false,
},
},
},
},
},
}),
},
},
});
@@ -123,11 +143,14 @@ it("do not render createPassword view when local auth is disabled", async () =>
it("send back access token", async () => {
const { context } = await createTestRenderer({
Query: {
viewer: {
email: "hans@test.com",
username: "hans",
profiles: [{ __typename: "LocalProfile" }],
resolvers: {
Query: {
viewer: () =>
pureMerge(viewer, {
email: "hans@test.com",
username: "hans",
profiles: [{ __typename: "LocalProfile" }],
}),
},
},
});
@@ -1,7 +1,9 @@
import { get } from "lodash";
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { pureMerge } from "coral-common/utils";
import { InvalidRequestError } from "coral-framework/lib/errors";
import {
act,
toJSON,
@@ -21,6 +23,7 @@ async function createTestRenderer(
...customResolver,
Query: {
...customResolver.Query,
viewer: sinon.stub().returns({ id: "me", profiles: [] }),
settings: sinon
.stub()
.returns(pureMerge(settings, get(customResolver, "Query.settings"))),
@@ -212,11 +215,50 @@ it("successfully sets email", async () => {
expect(confirmEmailAddressField.props.disabled).toBe(true);
expect(submitButton.props.disabled).toBe(true);
await wait(() => {
expect(setEmail.called).toBe(true);
});
});
it("switch to link account", async () => {
const email = "hans@test.com";
const setEmail = sinon.stub().callsFake((_: any, data: any) => {
throw new InvalidRequestError({ code: ERROR_CODES.DUPLICATE_EMAIL });
});
const {
testRenderer,
form,
emailAddressField,
confirmEmailAddressField,
} = await createTestRenderer(
{
Mutation: {
setEmail,
},
},
{ muteNetworkErrors: true }
);
const submitButton = form.find(
i => i.type === "button" && i.props.type === "submit"
);
act(() => emailAddressField.props.onChange({ target: { value: email } }));
act(() =>
confirmEmailAddressField.props.onChange({
target: { value: email },
})
);
act(() => {
form.props.onSubmit();
});
expect(emailAddressField.props.disabled).toBe(true);
expect(confirmEmailAddressField.props.disabled).toBe(true);
expect(submitButton.props.disabled).toBe(true);
await act(async () => {
await wait(() => {
expect(submitButton.props.disabled).toBe(false);
});
await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
});
expect(toJSON(form)).toMatchSnapshot();
expect(setEmail.called).toBe(true);
});
@@ -0,0 +1,149 @@
import sinon from "sinon";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createAccessToken,
createResolversStub,
CreateTestRendererParams,
wait,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "./create";
import { settings } from "./fixtures";
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
viewer: () => ({ id: "me", profiles: [] }),
settings: () => settings,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("LINK_ACCOUNT", "view");
localRecord.setValue("my@email.com", "duplicateEmail");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
const form = within(container).queryByType("form")!;
const passwordField = within(form).getByLabelText("Password");
return {
context,
testRenderer,
form,
container,
passwordField,
};
}
it("renders link account view", async () => {
const { testRenderer } = await createTestRenderer();
expect(testRenderer.toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
it("checks for required password", async () => {
const { form, container } = await createTestRenderer();
act(() => {
form.props.onSubmit();
});
within(container).getByText("This field is required", { exact: false });
});
it("performs account linking", async () => {
const { form, passwordField, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/auth/link", {
method: "POST",
body: {
email: "my@email.com",
password: "testtest",
},
})
.once()
.returns({
token: createAccessToken(),
});
act(() => {
passwordField.props.onChange("testtest");
});
act(() => {
form.props.onSubmit();
});
// Look for visual cues for form submission progress.
expect(passwordField.props.disabled).toBe(true);
await act(async () => {
await wait(() => expect(passwordField.props.disabled).toBe(false));
});
restMock.verify();
});
it("shows server error", async () => {
const { form, passwordField, context } = await createTestRenderer();
const error = new Error("Server Error");
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/auth/link", {
method: "POST",
body: {
email: "my@email.com",
password: "testtest",
},
})
.once()
.throws(error);
act(() => {
passwordField.props.onChange("testtest");
});
act(() => {
form.props.onSubmit();
});
// Look for visual cues for form submission progress.
expect(passwordField.props.disabled).toBe(true);
await act(async () => {
await wait(() => expect(passwordField.props.disabled).toBe(false));
});
within(form).getByText("Server Error", { exact: false });
restMock.verify();
});
it("change email view", async () => {
const { testRenderer, container } = await createTestRenderer();
const button = within(container).getByText("Use a different Email", {
exact: false,
});
act(() => {
button.props.onClick();
});
await act(async () => {
await waitForElement(() =>
within(testRenderer.root).getByTestID("addEmailAddress-container")
);
});
});
@@ -8,6 +8,11 @@ import ConfirmEmailField from "coral-auth/components/ConfirmEmailField";
import EmailField from "coral-auth/components/EmailField";
import Main from "coral-auth/components/Main";
import useResizePopup from "coral-auth/hooks/useResizePopup";
import {
SetDuplicateEmailMutation,
SetViewMutation,
} from "coral-auth/mutations";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { FormError, OnSubmit } from "coral-framework/lib/form";
import { useMutation } from "coral-framework/lib/relay";
import {
@@ -29,12 +34,22 @@ interface FormErrorProps extends FormProps, FormError {}
const AddEmailAddressContainer: FunctionComponent = () => {
const setEmail = useMutation(SetEmailMutation);
const setDuplicateEmail = useMutation(SetDuplicateEmailMutation);
const setView = useMutation(SetViewMutation);
const onSubmit: OnSubmit<FormErrorProps> = useCallback(
async (input, form) => {
try {
await setEmail({ email: input.email });
return;
} catch (error) {
if (error instanceof InvalidRequestError) {
if (error.code === "DUPLICATE_EMAIL") {
setDuplicateEmail({ duplicateEmail: input.email });
setView({ view: "LINK_ACCOUNT" });
return;
}
return error.invalidArgs;
}
return { [FORM_ERROR]: error.message };
}
},
@@ -0,0 +1,176 @@
import { Localized } from "@fluent/react/compat";
import { FORM_ERROR } from "final-form";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { Bar, Title } from "coral-auth/components//Header";
import Main from "coral-auth/components/Main";
import OrSeparator from "coral-auth/components/OrSeparator";
import useResizePopup from "coral-auth/hooks/useResizePopup";
import { SetViewMutation } from "coral-auth/mutations";
import {
colorFromMeta,
FormError,
OnSubmit,
ValidationMessage,
} from "coral-framework/lib/form";
import {
graphql,
useLocal,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import { required } from "coral-framework/lib/validation";
import {
Button,
CallOut,
FormField,
HorizontalGutter,
InputLabel,
PasswordField,
Typography,
} from "coral-ui/components";
import { LinkAccountContainer_viewer } from "coral-auth/__generated__/LinkAccountContainer_viewer.graphql";
import { LinkAccountContainerLocal } from "coral-auth/__generated__/LinkAccountContainerLocal.graphql";
import LinkAccountMutation from "./LinkAccountMutation";
interface FormProps {
password: string;
}
interface FormErrorProps extends FormProps, FormError {}
interface Props {
viewer: LinkAccountContainer_viewer | null;
}
const LinkAccountContainer: FunctionComponent<Props> = props => {
const [local] = useLocal<LinkAccountContainerLocal>(graphql`
fragment LinkAccountContainerLocal on Local {
duplicateEmail
}
`);
const duplicateEmail =
local.duplicateEmail || (props.viewer && props.viewer.duplicateEmail);
const setView = useMutation(SetViewMutation);
const linkAccount = useMutation(LinkAccountMutation);
const onSubmit: OnSubmit<FormErrorProps> = useCallback(
async (input, form) => {
if (!duplicateEmail) {
return { [FORM_ERROR]: "duplicate email not set" };
}
try {
await linkAccount({
email: duplicateEmail,
password: input.password,
});
return;
} catch (error) {
return { [FORM_ERROR]: error.message };
}
},
[linkAccount]
);
const changeEmail = useCallback(() => {
setView({ view: "ADD_EMAIL_ADDRESS" });
}, [setView]);
const ref = useResizePopup();
return (
<div ref={ref} data-testid="linkAccount-container">
<Bar>
<Localized id="linkAccount-linkAccountHeader">
<Title>Link Account</Title>
</Localized>
</Bar>
<Main data-testid="linkAccount-main">
<HorizontalGutter spacing={3}>
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="oneAndAHalf">
<Localized
id="linkAccount-alreadyAssociated"
$email={duplicateEmail}
strong={<strong />}
>
<Typography variant="bodyCopy">
The email <strong>{duplicateEmail}</strong> is already
associated with an account. If you would like to link
these enter your password.
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field name="password" validate={required}>
{({ input, meta }) => (
<FormField>
<Localized id="linkAccount-passwordLabel">
<InputLabel htmlFor={input.name}>Password</InputLabel>
</Localized>
<Localized
id="linkAccount-passwordTextField"
attrs={{ placeholder: true }}
>
<PasswordField
{...input}
id={input.name}
placeholder="Password"
color={colorFromMeta(meta)}
disabled={submitting}
fullWidth
/>
</Localized>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<Localized id="linkAccount-linkAccountButton">
<Button
variant="filled"
color="primary"
size="large"
type="submit"
fullWidth
disabled={submitting}
>
Link Account
</Button>
</Localized>
</HorizontalGutter>
</form>
)}
</Form>
<OrSeparator />
<Localized id="linkAccount-useDifferentEmail">
<Button
variant="filled"
size="large"
type="submit"
fullWidth
onClick={changeEmail}
>
Use a different email address
</Button>
</Localized>
</HorizontalGutter>
</Main>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment LinkAccountContainer_viewer on User {
duplicateEmail
}
`,
})(LinkAccountContainer);
export default enhanced;
@@ -0,0 +1,16 @@
import { pick } from "lodash";
import { createMutation } from "coral-framework/lib/relay";
import { linkAccount, LinkAccountInput } from "coral-framework/rest";
export type LinkAccountMutation = (input: LinkAccountInput) => Promise<void>;
const LinkAccountMutation = createMutation(
"linkAccount",
async (_, input: LinkAccountInput, { rest, clearSession }) => {
const result = await linkAccount(rest, pick(input, ["email", "password"]));
await clearSession(result.token);
}
);
export default LinkAccountMutation;
@@ -0,0 +1,4 @@
export {
default,
default as LinkAccountContainer,
} from "./LinkAccountContainer";
+1
View File
@@ -6,3 +6,4 @@ export {
default as forgotPassword,
ForgotPasswordInput,
} from "./forgotPassword";
export { default as linkAccount, LinkAccountInput } from "./linkAccount";
@@ -0,0 +1,17 @@
import { RestClient } from "../lib/rest";
export interface LinkAccountInput {
email: string;
password: string;
}
export interface LinkAccountResponse {
token: string;
}
export default function linkAccount(rest: RestClient, input: LinkAccountInput) {
return rest.fetch<LinkAccountResponse>("/auth/link", {
method: "POST",
body: input,
});
}
+4 -1
View File
@@ -6,7 +6,10 @@ export default function act<T>(callback: () => T): T {
let callbackResult: T;
const actResult = TestRenderer.act(() => {
callbackResult = callback();
return callbackResult as any;
if (isPromiseLike(callbackResult!)) {
return callbackResult as any;
}
return;
});
if (isPromiseLike(callbackResult!)) {
// Return it this way, to preserve warnings that React emits.
@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation } from "coral-framework/lib/relay";
import { SignOutMutation } from "coral-stream/mutations";
/**
* useHandleIncompleteAccount logs out a user when it
* encounters a `USER_NOT_ENTITLED` during a query. It accepts
* `data` from a `QueryRenderer`. Should only be used on Queries
* that normally does not fail except when the account
* is incomplete. Returns `true` if account was incomplete.
*/
const useHandleIncompleteAccount = () => {
const signOut = useMutation(SignOutMutation);
return useCallback(
(data: { error: Error | null }) => {
if (
data.error instanceof InvalidRequestError &&
data.error.code === ERROR_CODES.USER_NOT_ENTITLED
) {
// eslint-disable-next-line
console.log("Coral: User account is incomplete. Perform logout.")
signOut();
return true;
}
return false;
},
[signOut]
);
};
export default useHandleIncompleteAccount;
@@ -8,6 +8,7 @@ import {
withLocalStateContainer,
} from "coral-framework/lib/relay";
import Spinner from "coral-stream/common/Spinner";
import useHandleIncompleteAccount from "coral-stream/common/useHandleIncompleteAccount";
import { Delay } from "coral-ui/components";
import { PermalinkViewQuery as QueryTypes } from "coral-stream/__generated__/PermalinkViewQuery.graphql";
@@ -50,6 +51,7 @@ export const render = ({ error, props }: QueryRenderData<QueryTypes>) => {
const PermalinkViewQuery: FunctionComponent<Props> = ({
local: { commentID, storyID, storyURL },
}) => {
const handleIncompleteAccount = useHandleIncompleteAccount();
return (
<QueryRenderer<QueryTypes>
query={graphql`
@@ -77,7 +79,12 @@ const PermalinkViewQuery: FunctionComponent<Props> = ({
storyID,
storyURL,
}}
render={render}
render={data => {
if (handleIncompleteAccount(data)) {
return null;
}
return render(data);
}}
/>
);
};
@@ -8,6 +8,7 @@ import {
withLocalStateContainer,
} from "coral-framework/lib/relay";
import Spinner from "coral-stream/common/Spinner";
import useHandleIncompleteAccount from "coral-stream/common/useHandleIncompleteAccount";
import { Delay, Flex } from "coral-ui/components";
import { COMMENTS_TAB } from "coral-stream/__generated__/StreamContainerLocal.graphql";
@@ -67,6 +68,7 @@ const StreamQuery: FunctionComponent<Props> = props => {
const {
local: { storyID, storyURL, commentsTab },
} = props;
const handleIncompleteAccount = useHandleIncompleteAccount();
return (
<>
<QueryRenderer<QueryTypes>
@@ -88,6 +90,9 @@ const StreamQuery: FunctionComponent<Props> = props => {
storyURL,
}}
render={data => {
if (handleIncompleteAccount(data)) {
return null;
}
return render(data, commentsTab);
}}
/>
@@ -10,6 +10,7 @@ import {
withLocalStateContainer,
} from "coral-framework/lib/relay";
import Spinner from "coral-stream/common/Spinner";
import useHandleIncompleteAccount from "coral-stream/common/useHandleIncompleteAccount";
import { CallOut, Delay } from "coral-ui/components";
import { ProfileQuery as QueryTypes } from "coral-stream/__generated__/ProfileQuery.graphql";
@@ -79,28 +80,36 @@ export const render = ({ error, props }: QueryRenderData<QueryTypes>) => {
const ProfileQuery: FunctionComponent<Props> = ({
local: { storyID, storyURL },
}) => (
<QueryRenderer<QueryTypes>
query={graphql`
query ProfileQuery($storyID: ID, $storyURL: String) {
story: stream(id: $storyID, url: $storyURL) {
...ProfileContainer_story
}) => {
const handleIncompleteAccount = useHandleIncompleteAccount();
return (
<QueryRenderer<QueryTypes>
query={graphql`
query ProfileQuery($storyID: ID, $storyURL: String) {
story: stream(id: $storyID, url: $storyURL) {
...ProfileContainer_story
}
viewer {
...ProfileContainer_viewer
}
settings {
...ProfileContainer_settings
}
}
viewer {
...ProfileContainer_viewer
`}
variables={{
storyID,
storyURL,
}}
render={data => {
if (handleIncompleteAccount(data)) {
return null;
}
settings {
...ProfileContainer_settings
}
}
`}
variables={{
storyID,
storyURL,
}}
render={render}
/>
);
return render(data);
}}
/>
);
};
const enhanced = withLocalStateContainer(
graphql`
@@ -89,6 +89,9 @@ beforeEach(() => {
});
it("renders permalink view", async () => {
// axe checking takes a bit of time.
jest.setTimeout(10000);
const tabPane = await waitForElement(() =>
within(testRenderer.root).getByTestID("current-tab-pane")
);