mirror of
https://github.com/wassname/talk.git
synced 2026-07-03 04:03:56 +08:00
[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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
+18
@@ -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"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
<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-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
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 act(async () => {
|
||||
await wait(() => {
|
||||
expect(submitButton.props.disabled).toBe(false);
|
||||
});
|
||||
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 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";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppOptions } from "coral-server/app";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { IntegrationDisabled } from "coral-server/errors";
|
||||
import { hasEnabledAuthIntegration } from "coral-server/models/tenant";
|
||||
import { retrieveUserWithProfile } from "coral-server/models/user";
|
||||
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import {
|
||||
@@ -62,7 +63,7 @@ export const forgotHandler = ({
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
@@ -178,7 +179,7 @@ export const forgotResetHandler = ({
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
@@ -254,7 +255,7 @@ export const forgotCheckHandler = ({
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export * from "./forgot";
|
||||
export * from "./signup";
|
||||
export * from "./link";
|
||||
|
||||
export type LogoutOptions = Pick<AppOptions, "redis">;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Joi from "joi";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { linkUsersAvailable } from "coral-server/models/tenant";
|
||||
import { signTokenString } from "coral-server/services/jwt";
|
||||
import { link } from "coral-server/services/users";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export interface LinkBody {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const LinkBodySchema = Joi.object().keys({
|
||||
email: Joi.string()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.email(),
|
||||
password: Joi.string(),
|
||||
});
|
||||
|
||||
export type LinkOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "signingConfig" | "mailerQueue" | "redis" | "config"
|
||||
>;
|
||||
|
||||
export const linkHandler = ({
|
||||
redis,
|
||||
mongo,
|
||||
signingConfig,
|
||||
config,
|
||||
}: LinkOptions): RequestHandler => {
|
||||
const ipLimiter = new RequestLimiter({
|
||||
redis,
|
||||
ttl: "10m",
|
||||
max: 10,
|
||||
prefix: "ip",
|
||||
config,
|
||||
});
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Rate limit based on the IP address and user agent.
|
||||
await ipLimiter.test(req, req.ip);
|
||||
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!linkUsersAvailable(tenant)) {
|
||||
throw new Error("cannot link users, not available");
|
||||
}
|
||||
|
||||
// Get the fields from the body. Validate will throw an error if the body
|
||||
// does not conform to the specification.
|
||||
const { email, password }: LinkBody = validate(LinkBodySchema, req.body);
|
||||
|
||||
// Start the account linking process. We are assured the user at this
|
||||
// point because of the middleware inserted before which rejects any
|
||||
// unauthenticated requests.
|
||||
const user = await link(mongo, tenant, req.user!, { email, password });
|
||||
|
||||
// Account linking is complete! Return the new access token for the
|
||||
// request.
|
||||
const token = await signTokenString(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{},
|
||||
coral.now
|
||||
);
|
||||
|
||||
return res.json({ token });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -6,12 +6,14 @@ import { handleSuccessfulLogin } from "coral-server/app/middleware/passport";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { IntegrationDisabled } from "coral-server/errors";
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
import { hasEnabledAuthIntegration } from "coral-server/models/tenant";
|
||||
import { LocalProfile, User } from "coral-server/models/user";
|
||||
import { create } from "coral-server/services/users";
|
||||
import { sendConfirmationEmail } from "coral-server/services/users/auth";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export interface SignupBody {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -57,7 +59,7 @@ export const signupHandler = ({
|
||||
const now = req.coral!.now;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { UserForbiddenError } from "coral-server/errors";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export const loggedInMiddleware: RequestHandler = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next(
|
||||
new UserForbiddenError(
|
||||
"user is not logged in",
|
||||
req.originalUrl,
|
||||
req.method
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import { CookieOptions, NextFunction, RequestHandler, Response } from "express";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { DateTime } from "luxon";
|
||||
import passport, { Authenticator } from "passport";
|
||||
|
||||
import { stringifyQuery } from "coral-common/utils";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import FacebookStrategy from "coral-server/app/middleware/passport/strategies/facebook";
|
||||
import GoogleStrategy from "coral-server/app/middleware/passport/strategies/google";
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
signTokenString,
|
||||
} from "coral-server/services/jwt";
|
||||
import { Request } from "coral-server/types/express";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export type VerifyCallback = (
|
||||
err?: Error | null,
|
||||
@@ -126,9 +127,7 @@ export async function handleSuccessfulLogin(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{
|
||||
expiresIn: tenant.auth.sessionDuration,
|
||||
},
|
||||
{},
|
||||
coral.now
|
||||
);
|
||||
|
||||
@@ -159,6 +158,14 @@ const generateCookieOptions = (
|
||||
expires: expiresIn,
|
||||
});
|
||||
|
||||
function redirectWithHash(
|
||||
res: Response,
|
||||
path: string,
|
||||
hash: Record<string, any>
|
||||
) {
|
||||
res.redirect(`${path}${stringifyQuery(hash, "#")}`);
|
||||
}
|
||||
|
||||
export async function handleOAuth2Callback(
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
@@ -173,36 +180,37 @@ export async function handleOAuth2Callback(
|
||||
err = new Error("user not on request");
|
||||
}
|
||||
|
||||
return res.redirect(path + `#error=${encodeURIComponent(err.message)}`);
|
||||
return redirectWithHash(res, path, { error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.coral!.tenant!;
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Compute the expiry date.
|
||||
const expiresIn = DateTime.fromJSDate(req.coral!.now).plus({
|
||||
const expiresIn = DateTime.fromJSDate(coral.now).plus({
|
||||
seconds: tenant.auth.sessionDuration,
|
||||
});
|
||||
|
||||
// Grab the token.
|
||||
const token = await signTokenString(
|
||||
const accessToken = await signTokenString(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{ expiresIn: tenant.auth.sessionDuration },
|
||||
req.coral!.now
|
||||
{},
|
||||
coral.now
|
||||
);
|
||||
res.cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
accessToken,
|
||||
generateCookieOptions(req, expiresIn.toJSDate())
|
||||
);
|
||||
|
||||
// Send back the details!
|
||||
res.redirect(path + `#accessToken=${token}`);
|
||||
return redirectWithHash(res, path, { accessToken });
|
||||
} catch (e) {
|
||||
res.redirect(path + `#error=${encodeURIComponent(e.message)}`);
|
||||
return redirectWithHash(res, path, { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,47 +50,45 @@ export default class FacebookStrategy extends OAuth2Strategy<
|
||||
id,
|
||||
};
|
||||
|
||||
let user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (!user) {
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: implement rules.
|
||||
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
user = await findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
const user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// TODO: maybe update user details?
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
// FIXME: implement rules.
|
||||
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
return findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
protected createStrategy(
|
||||
|
||||
@@ -49,47 +49,43 @@ export default class GoogleStrategy extends OAuth2Strategy<
|
||||
id,
|
||||
};
|
||||
|
||||
let user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (!user) {
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: implement rules.
|
||||
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
user = await findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
const user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// TODO: maybe update user details?
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
return findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
protected createStrategy(
|
||||
|
||||
@@ -72,18 +72,18 @@ export default abstract class OAuth2Strategy<
|
||||
) => {
|
||||
try {
|
||||
// Coral is defined at this point.
|
||||
const tenant = req.coral!.tenant!;
|
||||
const now = req.coral!.now;
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Get the integration.
|
||||
const integration = this.getIntegration(tenant.auth.integrations);
|
||||
|
||||
// Get the user.
|
||||
// Find or create the user.
|
||||
const user = await this.findOrCreateUser(
|
||||
tenant,
|
||||
integration as Required<T>,
|
||||
profile,
|
||||
now
|
||||
coral.now
|
||||
);
|
||||
if (!user) {
|
||||
return done(null);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import {
|
||||
JWTSigningConfig,
|
||||
signTokenString,
|
||||
SymmetricSigningAlgorithm,
|
||||
} from "coral-server/services/jwt";
|
||||
import { isJWTToken } from "./jwt";
|
||||
|
||||
import { isJWTToken, JWTToken } from "./jwt";
|
||||
|
||||
// Create signing config.
|
||||
const config: JWTSigningConfig = {
|
||||
@@ -15,13 +17,22 @@ const config: JWTSigningConfig = {
|
||||
|
||||
it("validates a jwt token", async () => {
|
||||
const user = { id: "user-id" };
|
||||
const tenant = { id: "tenant-id" };
|
||||
const tenant = { id: "tenant-id", auth: { sessionDuration: 100 } };
|
||||
|
||||
// Get the time.
|
||||
const now = new Date();
|
||||
now.setMilliseconds(0);
|
||||
|
||||
// Create the signed token string.
|
||||
const tokenString = await signTokenString(config, user, tenant);
|
||||
const tokenString = await signTokenString(config, user, tenant, {}, now);
|
||||
|
||||
// Verify that the token conforms to the JWT token schema.
|
||||
const token = jwt.decode(tokenString) as object;
|
||||
|
||||
expect(isJWTToken(token)).toBeTruthy();
|
||||
expect((token as JWTToken).exp).toBe(
|
||||
DateTime.fromJSDate(now)
|
||||
.plus({ seconds: 100 })
|
||||
.toSeconds()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
forgotCheckHandler,
|
||||
forgotHandler,
|
||||
forgotResetHandler,
|
||||
linkHandler,
|
||||
logoutHandler,
|
||||
signupHandler,
|
||||
} from "coral-server/app/handlers";
|
||||
import { noCacheMiddleware } from "coral-server/app/middleware/cacheHeaders";
|
||||
import { jsonMiddleware } from "coral-server/app/middleware/json";
|
||||
import { loggedInMiddleware } from "coral-server/app/middleware/loggedIn";
|
||||
import {
|
||||
authenticate,
|
||||
wrapAuthn,
|
||||
@@ -48,6 +50,15 @@ export function createNewAuthRouter(
|
||||
router.put("/local/forgot", jsonMiddleware, forgotResetHandler(app));
|
||||
router.post("/local/forgot", jsonMiddleware, forgotHandler(app));
|
||||
|
||||
// Mount the link handler.
|
||||
router.post(
|
||||
"/link",
|
||||
authenticate(passport),
|
||||
loggedInMiddleware,
|
||||
jsonMiddleware,
|
||||
linkHandler(app)
|
||||
);
|
||||
|
||||
// Mount the logout handler.
|
||||
router.delete("/", authenticate(passport), logoutHandler(app));
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [events](#events)
|
||||
- [Adding new events](#adding-new-events)
|
||||
- [Adding new event listeners](#adding-new-event-listeners)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
# events
|
||||
|
||||
This is the events package for Coral.
|
||||
|
||||
@@ -1944,6 +1944,13 @@ type User {
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
|
||||
"""
|
||||
duplicateEmail is set on users that have a matching email with another user in
|
||||
the database. Only relevant during the account completion process.
|
||||
"""
|
||||
duplicateEmail: String
|
||||
@auth(userIDField: "id", permit: [MISSING_NAME, MISSING_EMAIL])
|
||||
|
||||
"""
|
||||
emailVerified when true indicates that the given email address has been
|
||||
verified.
|
||||
@@ -1972,7 +1979,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ import crypto from "crypto";
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLReactionConfiguration,
|
||||
GQLStaffConfiguration,
|
||||
@@ -68,6 +69,21 @@ export function hasFeatureFlag(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasEnabledAuthIntegration(
|
||||
tenant: Pick<Tenant, "auth">,
|
||||
integration: keyof GQLAuthIntegrations
|
||||
) {
|
||||
return tenant.auth.integrations[integration].enabled;
|
||||
}
|
||||
|
||||
export function linkUsersAvailable(tenant: Pick<Tenant, "auth">) {
|
||||
return (
|
||||
hasEnabledAuthIntegration(tenant, "local") &&
|
||||
(hasEnabledAuthIntegration(tenant, "facebook") ||
|
||||
hasEnabledAuthIntegration(tenant, "google"))
|
||||
);
|
||||
}
|
||||
|
||||
export function getWebhookEndpoint(
|
||||
tenant: Pick<Tenant, "webhooks">,
|
||||
endpointID: string
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
import { STAFF_ROLES } from "./constants";
|
||||
import { LocalProfile, SSOProfile, User } from "./user";
|
||||
import { LocalProfile, Profile, SSOProfile, User } from "./user";
|
||||
|
||||
export function roleIsStaff(role: GQLUSER_ROLE) {
|
||||
if (STAFF_ROLES.includes(role)) {
|
||||
@@ -18,14 +19,19 @@ export function hasStaffRole(user: Pick<User, "role">) {
|
||||
return roleIsStaff(user.role);
|
||||
}
|
||||
|
||||
export function getSSOProfile(user: Pick<User, "profiles">) {
|
||||
export function getUserProfile(
|
||||
user: Pick<User, "profiles">,
|
||||
type: Profile["type"]
|
||||
) {
|
||||
if (!user.profiles) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.profiles.find(profile => profile.type === "sso") as
|
||||
| SSOProfile
|
||||
| undefined;
|
||||
return user.profiles.find(p => p.type === type) || null;
|
||||
}
|
||||
|
||||
export function getSSOProfile(user: Pick<User, "profiles">) {
|
||||
return getUserProfile(user, "sso") as SSOProfile | null;
|
||||
}
|
||||
|
||||
export function needsSSOUpdate(
|
||||
@@ -50,14 +56,7 @@ export function getLocalProfile(
|
||||
user: Pick<User, "profiles">,
|
||||
withEmail?: string
|
||||
): LocalProfile | undefined {
|
||||
if (!user.profiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = user.profiles.find(({ type }) => type === "local") as
|
||||
| LocalProfile
|
||||
| undefined;
|
||||
|
||||
const profile = getUserProfile(user, "local") as LocalProfile | null;
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ConfirmEmailTokenExpired,
|
||||
DuplicateEmailError,
|
||||
DuplicateUserError,
|
||||
EmailAlreadySetError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
PasswordResetTokenExpired,
|
||||
@@ -408,6 +409,13 @@ export interface User extends TenantResource {
|
||||
*/
|
||||
emailVerified?: boolean;
|
||||
|
||||
/**
|
||||
* duplicateEmail is used to store the email address that was associated with
|
||||
* the user account on creation that at the time was previously associated
|
||||
* with another local account.
|
||||
*/
|
||||
duplicateEmail?: string;
|
||||
|
||||
/**
|
||||
* profiles is the array of profiles assigned to the user. When a user deletes
|
||||
* their account, this is unset.
|
||||
@@ -492,6 +500,7 @@ export interface FindOrCreateUserInput {
|
||||
badges?: string[];
|
||||
ssoURL?: string;
|
||||
emailVerified?: boolean;
|
||||
duplicateEmail?: string;
|
||||
role: GQLUSER_ROLE;
|
||||
profile: Profile;
|
||||
}
|
||||
@@ -1059,6 +1068,9 @@ export async function setUserEmail(
|
||||
$set: {
|
||||
email,
|
||||
},
|
||||
$unset: {
|
||||
duplicateEmail: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
@@ -1074,7 +1086,7 @@ export async function setUserEmail(
|
||||
}
|
||||
|
||||
if (user.email) {
|
||||
throw new UsernameAlreadySetError();
|
||||
throw new EmailAlreadySetError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
@@ -2472,6 +2484,50 @@ export async function deleteModeratorNote(
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function linkUsers(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
sourceUserID: string,
|
||||
destinationUserID: string
|
||||
) {
|
||||
// Delete the old user from the database.
|
||||
const source = await collection(mongo).findOneAndDelete({
|
||||
id: sourceUserID,
|
||||
tenantID,
|
||||
});
|
||||
if (!source.value) {
|
||||
throw new UserNotFoundError(sourceUserID);
|
||||
}
|
||||
|
||||
// If the source user doesn't have any profiles, we have nothing to do. We
|
||||
// should abort.
|
||||
if (!source.value.profiles) {
|
||||
throw new Error(
|
||||
"cannot link a user with no profiles, failed source authentication precondition"
|
||||
);
|
||||
}
|
||||
|
||||
// Add the new profile to the destination user.
|
||||
const dest = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id: destinationUserID,
|
||||
tenantID,
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
profiles: {
|
||||
$each: source.value.profiles,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!dest.value) {
|
||||
throw new UserNotFoundError(destinationUserID);
|
||||
}
|
||||
|
||||
return dest.value;
|
||||
}
|
||||
|
||||
export const updateUserCommentCounts = (
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
JWTRevokedError,
|
||||
TokenInvalidError,
|
||||
} from "coral-server/errors";
|
||||
import { Auth } from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { Request } from "coral-server/types/express";
|
||||
@@ -251,7 +252,9 @@ export type SigningTokenOptions = Pick<
|
||||
export const signTokenString = async (
|
||||
{ algorithm, secret }: JWTSigningConfig,
|
||||
user: Pick<User, "id">,
|
||||
tenant: Pick<Tenant, "id">,
|
||||
tenant: Pick<Tenant, "id"> & {
|
||||
auth: Pick<Auth, "sessionDuration">;
|
||||
},
|
||||
options: SigningTokenOptions = {},
|
||||
now = new Date()
|
||||
) =>
|
||||
@@ -262,7 +265,7 @@ export const signTokenString = async (
|
||||
secret,
|
||||
{
|
||||
jwtid: uuid(),
|
||||
expiresIn: DEFAULT_SESSION_DURATION,
|
||||
expiresIn: tenant.auth.sessionDuration || DEFAULT_SESSION_DURATION,
|
||||
...options,
|
||||
issuer: tenant.id,
|
||||
subject: user.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { intersection } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
DuplicateUserError,
|
||||
EmailAlreadySetError,
|
||||
EmailNotSetError,
|
||||
InvalidCredentialsError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
PasswordIncorrect,
|
||||
@@ -27,7 +29,7 @@ import {
|
||||
} from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import { Comment, retrieveComment } from "coral-server/models/comment";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { linkUsersAvailable, Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
banUser,
|
||||
clearDeletionDate,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
findOrCreateUser,
|
||||
FindOrCreateUserInput,
|
||||
ignoreUser,
|
||||
linkUsers,
|
||||
NotificationSettingsInput,
|
||||
premodUser,
|
||||
removeActiveUserSuspensions,
|
||||
@@ -126,19 +129,43 @@ export async function findOrCreate(
|
||||
// Validate the input.
|
||||
validateFindOrCreateUserInput(input, options);
|
||||
|
||||
const { user, wasUpserted } = await findOrCreateUser(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
now
|
||||
);
|
||||
let user: Readonly<User>;
|
||||
let wasUpserted: boolean;
|
||||
|
||||
try {
|
||||
const result = await findOrCreateUser(mongo, tenant.id, input, now);
|
||||
user = result.user;
|
||||
wasUpserted = result.wasUpserted;
|
||||
} catch (err) {
|
||||
// If this is an error related to a duplicate email, we might be in a
|
||||
// position where the user can link their accounts. This can only occur if
|
||||
// the tenant has both local and another social profile enabled.
|
||||
if (err instanceof DuplicateEmailError && linkUsersAvailable(tenant)) {
|
||||
// Pull the email address out of the input, and re-try creating the user
|
||||
// given that.
|
||||
const { email, emailVerified, ...rest } = input;
|
||||
|
||||
// Create the user again this time, but associate the duplicate email to
|
||||
// the user account.
|
||||
const result = await findOrCreateUser(
|
||||
mongo,
|
||||
tenant.id,
|
||||
{ ...rest, duplicateEmail: email },
|
||||
now
|
||||
);
|
||||
|
||||
user = result.user;
|
||||
wasUpserted = result.wasUpserted;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (wasUpserted) {
|
||||
// TODO: (wyattjoh) emit that a user was created
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -1262,3 +1289,76 @@ export async function retrieveUserLastComment(
|
||||
|
||||
return retrieveComment(mongo, tenant.id, id);
|
||||
}
|
||||
|
||||
export interface LinkUser {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function link(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
source: User,
|
||||
{ email, password }: LinkUser
|
||||
) {
|
||||
if (!linkUsersAvailable(tenant)) {
|
||||
throw new Error("cannot link users, not available");
|
||||
}
|
||||
|
||||
// TODO: validate the source user
|
||||
|
||||
// Refuse to link a user that already has an email address.
|
||||
if (source.email || hasLocalProfile(source)) {
|
||||
throw new Error("user already has an email linked to the source account");
|
||||
}
|
||||
|
||||
// Validate the input. If the values do not pass validation, it can't possibly
|
||||
// be correct.
|
||||
validateEmail(email);
|
||||
|
||||
// Validate if the credentials are correct.
|
||||
const destination = await retrieveUserWithEmail(mongo, tenant.id, email);
|
||||
if (!destination) {
|
||||
throw new InvalidCredentialsError(
|
||||
"can't find user linked with email address"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that the source and destination user aren't the same.
|
||||
if (destination.id === source.id) {
|
||||
throw new Error("cannot link the same accounts together");
|
||||
}
|
||||
|
||||
// Ensure that the destination profile has a local profile.
|
||||
if (!hasLocalProfile(destination, email)) {
|
||||
throw new Error("destination user does not have a local profile");
|
||||
}
|
||||
|
||||
// Ensure there is no clash between the source and destination user profiles.
|
||||
const profiles = {
|
||||
destination: (destination.profiles || []).map(p => p.type),
|
||||
source: (source.profiles || []).map(p => p.type),
|
||||
};
|
||||
|
||||
// Check for any intersecting profiles.
|
||||
const intersecting = intersection(profiles.destination, profiles.source);
|
||||
if (intersecting.length > 0) {
|
||||
throw new Error(
|
||||
`user linking found intersecting profiles: ${intersecting}`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify if the password provided is correct for this account.
|
||||
const verified = await verifyUserPassword(destination, password, email);
|
||||
if (!verified) {
|
||||
throw new InvalidCredentialsError("can't verify password for linking");
|
||||
}
|
||||
|
||||
// Perform the account linking step to delete the source user and copy over
|
||||
// the account profiles.
|
||||
const linked = await linkUsers(mongo, tenant.id, source.id, destination.id);
|
||||
|
||||
// TODO: send an email to the linked user
|
||||
|
||||
return linked;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ login-signIn-passwordTextField =
|
||||
.placeholder = Adgangskode
|
||||
|
||||
login-signIn-signInWithEmail = Log ind med e-mail
|
||||
login-signIn-orSeparator = Eller
|
||||
login-orSeparator = Eller
|
||||
|
||||
login-signInWithFacebook = Log ind med Facebook
|
||||
login-signInWithGoogle = Log ind med Google
|
||||
|
||||
+69
-56
@@ -59,13 +59,81 @@ login-signIn-passwordTextField =
|
||||
.placeholder = Password
|
||||
|
||||
login-signIn-signInWithEmail = Sign in with Email
|
||||
login-signIn-orSeparator = Or
|
||||
login-orSeparator = Or
|
||||
login-signIn-forgot-password = Forgot your password?
|
||||
|
||||
login-signInWithFacebook = Sign in with Facebook
|
||||
login-signInWithGoogle = Sign in with Google
|
||||
login-signInWithOIDC = Sign in with { $name }
|
||||
|
||||
# Create Username
|
||||
|
||||
createUsername-createUsernameHeader = Create Username
|
||||
createUsername-whatItIs =
|
||||
Your username is an identifier that will appear on all of your comments.
|
||||
createUsername-createUsernameButton = Create Username
|
||||
createUsername-usernameLabel = Username
|
||||
createUsername-usernameDescription = You may use “_” and “.” Spaces not permitted.
|
||||
createUsername-usernameTextField =
|
||||
.placeholder = Username
|
||||
|
||||
# Add Email Address
|
||||
addEmailAddress-addEmailAddressHeader = Add Email Address
|
||||
|
||||
addEmailAddress-emailAddressLabel = Email Address
|
||||
addEmailAddress-emailAddressTextField =
|
||||
.placeholder = Email Address
|
||||
|
||||
addEmailAddress-confirmEmailAddressLabel = Confirm Email Address
|
||||
addEmailAddress-confirmEmailAddressTextField =
|
||||
.placeholder = Confirm Email Address
|
||||
|
||||
addEmailAddress-whatItIs =
|
||||
For your added security, we require users to add an email address to their accounts.
|
||||
|
||||
addEmailAddress-addEmailAddressButton =
|
||||
Add Email Address
|
||||
|
||||
# Create Password
|
||||
createPassword-createPasswordHeader = Create Password
|
||||
createPassword-whatItIs =
|
||||
To protect against unauthorized changes to your account,
|
||||
we require users to create a password.
|
||||
createPassword-createPasswordButton =
|
||||
Create Password
|
||||
|
||||
createPassword-passwordLabel = Password
|
||||
createPassword-passwordDescription = Must be at least {$minLength} characters
|
||||
createPassword-passwordTextField =
|
||||
.placeholder = Password
|
||||
|
||||
# Forgot Password
|
||||
forgotPassword-forgotPasswordHeader = Forgot password?
|
||||
forgotPassword-checkEmailHeader = Check your email
|
||||
forgotPassword-gotBackToSignIn = Go back to sign in page
|
||||
forgotPassword-checkEmail-receiveEmail =
|
||||
If there is an account associated with <strong>{ $email }</strong>,
|
||||
you will receive an email with a link to create a new password.
|
||||
forgotPassword-enterEmailAndGetALink =
|
||||
Enter your email address below and we will send you a link
|
||||
to reset your password.
|
||||
forgotPassword-emailAddressLabel = Email address
|
||||
forgotPassword-emailAddressTextField =
|
||||
.placeholder = Email Address
|
||||
forgotPassword-sendEmailButton = Send email
|
||||
|
||||
# Link Account
|
||||
linkAccount-linkAccountHeader = Link Account
|
||||
linkAccount-alreadyAssociated =
|
||||
The email <strong>{ $email }</strong> is
|
||||
already associated with an account. If you would like to
|
||||
link these enter your password.
|
||||
linkAccount-passwordLabel = Password
|
||||
linkAccount-passwordTextField =
|
||||
.label = Password
|
||||
linkAccount-linkAccountButton = Link Account
|
||||
linkAccount-useDifferentEmail = Use a different email address
|
||||
|
||||
## Configure
|
||||
|
||||
configure-unsavedInputWarning =
|
||||
@@ -731,47 +799,6 @@ moderate-user-drawer-notes-button = Add note
|
||||
moderatorNote-left-by = Left by
|
||||
moderatorNote-delete = Delete
|
||||
|
||||
## Create Username
|
||||
|
||||
createUsername-createUsernameHeader = Create Username
|
||||
createUsername-whatItIs =
|
||||
Your username is an identifier that will appear on all of your comments.
|
||||
createUsername-createUsernameButton = Create Username
|
||||
createUsername-usernameLabel = Username
|
||||
createUsername-usernameDescription = You may use “_” and “.” Spaces not permitted.
|
||||
createUsername-usernameTextField =
|
||||
.placeholder = Username
|
||||
|
||||
## Add Email Address
|
||||
addEmailAddress-addEmailAddressHeader = Add Email Address
|
||||
|
||||
addEmailAddress-emailAddressLabel = Email Address
|
||||
addEmailAddress-emailAddressTextField =
|
||||
.placeholder = Email Address
|
||||
|
||||
addEmailAddress-confirmEmailAddressLabel = Confirm Email Address
|
||||
addEmailAddress-confirmEmailAddressTextField =
|
||||
.placeholder = Confirm Email Address
|
||||
|
||||
addEmailAddress-whatItIs =
|
||||
For your added security, we require users to add an email address to their accounts.
|
||||
|
||||
addEmailAddress-addEmailAddressButton =
|
||||
Add Email Address
|
||||
|
||||
## Create Password
|
||||
createPassword-createPasswordHeader = Create Password
|
||||
createPassword-whatItIs =
|
||||
To protect against unauthorized changes to your account,
|
||||
we require users to create a password.
|
||||
createPassword-createPasswordButton =
|
||||
Create Password
|
||||
|
||||
createPassword-passwordLabel = Password
|
||||
createPassword-passwordDescription = Must be at least {$minLength} characters
|
||||
createPassword-passwordTextField =
|
||||
.placeholder = Password
|
||||
|
||||
## Community
|
||||
community-emptyMessage = We could not find anyone in your community matching your criteria.
|
||||
|
||||
@@ -1013,20 +1040,6 @@ configure-advanced-stories-custom-user-agent-detail =
|
||||
When specified, overrides the <code>User-Agent</code> header sent with each
|
||||
scrape request.
|
||||
|
||||
forgotPassword-forgotPasswordHeader = Forgot password?
|
||||
forgotPassword-checkEmailHeader = Check your email
|
||||
forgotPassword-gotBackToSignIn = Go back to sign in page
|
||||
forgotPassword-checkEmail-receiveEmail =
|
||||
If there is an account associated with <strong>{ $email }</strong>,
|
||||
you will receive an email with a link to create a new password.
|
||||
forgotPassword-enterEmailAndGetALink =
|
||||
Enter your email address below and we will send you a link
|
||||
to reset your password.
|
||||
forgotPassword-emailAddressLabel = Email address
|
||||
forgotPassword-emailAddressTextField =
|
||||
.placeholder = Email Address
|
||||
forgotPassword-sendEmailButton = Send email
|
||||
|
||||
commentAuthor-status-banned = Banned
|
||||
|
||||
hotkeysModal-title = Keyboard shortcuts
|
||||
|
||||
@@ -68,7 +68,7 @@ forgotPassword-enterEmailAndGetALink =
|
||||
Enter your email address below and we will send you a link to
|
||||
reset your password.
|
||||
|
||||
### Check Email
|
||||
# Check Email
|
||||
|
||||
forgotPassword-checkEmail-checkEmailHeader = Check Your Email
|
||||
forgotPassword-checkEmail-receiveEmail =
|
||||
@@ -76,6 +76,19 @@ forgotPassword-checkEmail-receiveEmail =
|
||||
you will receive an email with a link to create a new password.
|
||||
forgotPassword-checkEmail-closeButton = Close
|
||||
|
||||
## Link Account
|
||||
|
||||
linkAccount-linkAccountHeader = Link Account
|
||||
linkAccount-alreadyAssociated =
|
||||
The email <strong>{ $email }</strong> is
|
||||
already associated with an account. If you would like to
|
||||
link these enter your password.
|
||||
linkAccount-passwordLabel = Password
|
||||
linkAccount-passwordTextField =
|
||||
.label = Password
|
||||
linkAccount-linkAccountButton = Link Account
|
||||
linkAccount-useDifferentEmail = Use a different email address
|
||||
|
||||
## Reset Password
|
||||
|
||||
resetPassword-resetPasswordHeader = Reset Password
|
||||
|
||||
@@ -59,7 +59,7 @@ login-signIn-passwordTextField =
|
||||
.placeholder = Mot de passe
|
||||
|
||||
login-signIn-signInWithEmail = Connectez-vous en utilisant votre email
|
||||
login-signIn-orSeparator = ou
|
||||
login-orSeparator = ou
|
||||
login-signIn-forgot-password = Mot de passe oublié ?
|
||||
|
||||
login-signInWithFacebook = Se connecter avec Facebook
|
||||
|
||||
@@ -59,7 +59,7 @@ login-signIn-passwordTextField =
|
||||
.placeholder = Senha
|
||||
|
||||
login-signIn-signInWithEmail = Entrar com o e-mail
|
||||
login-signIn-orSeparator = Ou
|
||||
login-orSeparator = Ou
|
||||
login-signIn-forgot-password = Esqueceu sua senha?
|
||||
|
||||
login-signInWithFacebook = Entrar com Facebook
|
||||
|
||||
@@ -59,7 +59,7 @@ login-signIn-passwordTextField =
|
||||
.placeholder = Password
|
||||
|
||||
login-signIn-signInWithEmail = Sign in with Email
|
||||
login-signIn-orSeparator = Or
|
||||
login-orSeparator = Or
|
||||
login-signIn-forgot-password = Forgot your password?
|
||||
|
||||
login-signInWithFacebook = Sign in with Facebook
|
||||
|
||||
Reference in New Issue
Block a user