diff --git a/src/core/client/admin/local/__snapshots__/initLocalState.spec.ts.snap b/src/core/client/admin/local/__snapshots__/initLocalState.spec.ts.snap new file mode 100644 index 000000000..2ed5c0035 --- /dev/null +++ b/src/core/client/admin/local/__snapshots__/initLocalState.spec.ts.snap @@ -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 + } +}" +`; diff --git a/src/core/client/admin/local/initLocalState.spec.ts b/src/core/client/admin/local/initLocalState.spec.ts new file mode 100644 index 000000000..1d7690485 --- /dev/null +++ b/src/core/client/admin/local/initLocalState.spec.ts @@ -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(); +}); diff --git a/src/core/client/admin/local/local.graphql b/src/core/client/admin/local/local.graphql index a9516b153..754d1be5b 100644 --- a/src/core/client/admin/local/local.graphql +++ b/src/core/client/admin/local/local.graphql @@ -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 } diff --git a/src/core/client/admin/routes/AuthCheck/AuthCheckRoute.tsx b/src/core/client/admin/routes/AuthCheck/AuthCheckRoute.tsx index ab912c939..dff36cb4b 100644 --- a/src/core/client/admin/routes/AuthCheck/AuthCheckRoute.tsx +++ b/src/core/client/admin/routes/AuthCheck/AuthCheckRoute.tsx @@ -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; diff --git a/src/core/client/admin/routes/Login/AccountCompletionContainer.tsx b/src/core/client/admin/routes/Login/AccountCompletionContainer.tsx index 99cbd2e7a..e2215454c 100644 --- a/src/core/client/admin/routes/Login/AccountCompletionContainer.tsx +++ b/src/core/client/admin/routes/Login/AccountCompletionContainer.tsx @@ -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 } diff --git a/src/core/client/admin/routes/Login/CompleteAccountBox.tsx b/src/core/client/admin/routes/Login/CompleteAccountBox.tsx index 9a16d184f..700bf4a60 100644 --- a/src/core/client/admin/routes/Login/CompleteAccountBox.tsx +++ b/src/core/client/admin/routes/Login/CompleteAccountBox.tsx @@ -9,7 +9,11 @@ interface Props { children: React.ReactNode; } -const CompleteAccountBox: FunctionComponent = ({ title, children }) => { +const CompleteAccountBox: FunctionComponent = ({ + title, + children, + ...rest +}) => { return (
@@ -27,7 +31,9 @@ const CompleteAccountBox: FunctionComponent = ({ title, children }) => { {title}
-
{children}
+
+
{children}
+
diff --git a/src/core/client/admin/routes/Login/Login.tsx b/src/core/client/admin/routes/Login/Login.tsx index ca00202fa..71d3b9fe4 100644 --- a/src/core/client/admin/routes/Login/Login.tsx +++ b/src/core/client/admin/routes/Login/Login.tsx @@ -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["auth"]; + viewer: PropTypesOf["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 ; @@ -30,14 +37,16 @@ const renderView = (view: Props["view"], auth: Props["auth"]) => { case "CREATE_PASSWORD": return ; case "ADD_EMAIL_ADDRESS": - return ; + return ; + case "LINK_ACCOUNT": + return ; default: throw new Error(`Unknown view ${view}`); } }; -const Login: FunctionComponent = ({ view, auth }) => ( -
{renderView(view, auth)}
+const Login: FunctionComponent = ({ view, auth, viewer }) => ( +
{renderView(view, auth, viewer)}
); export default Login; diff --git a/src/core/client/admin/routes/Login/LoginRoute.tsx b/src/core/client/admin/routes/Login/LoginRoute.tsx index 1a4f6dace..2765ca8f0 100644 --- a/src/core/client/admin/routes/Login/LoginRoute.tsx +++ b/src/core/client/admin/routes/Login/LoginRoute.tsx @@ -32,6 +32,7 @@ class LoginRoute extends Component { ); @@ -43,6 +44,7 @@ const enhanced = withRouteConfig({ query LoginRouteQuery { viewer { ...AccountCompletionContainer_viewer + ...LinkAccountContainer_viewer } settings { auth { diff --git a/src/core/client/admin/routes/Login/SetAuthViewMutation.ts b/src/core/client/admin/routes/Login/SetAuthViewMutation.ts index 2e573e6e1..4c6b28cd2 100644 --- a/src/core/client/admin/routes/Login/SetAuthViewMutation.ts +++ b/src/core/client/admin/routes/Login/SetAuthViewMutation.ts @@ -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( diff --git a/src/core/client/admin/routes/Login/SetDuplicateEmailMutation.ts b/src/core/client/admin/routes/Login/SetDuplicateEmailMutation.ts new file mode 100644 index 000000000..bc0dc4b51 --- /dev/null +++ b/src/core/client/admin/routes/Login/SetDuplicateEmailMutation.ts @@ -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; diff --git a/src/core/client/admin/routes/Login/__snapshots__/CompleteAccountBox.spec.tsx.snap b/src/core/client/admin/routes/Login/__snapshots__/CompleteAccountBox.spec.tsx.snap index 32b4cfe35..e84f83aca 100644 --- a/src/core/client/admin/routes/Login/__snapshots__/CompleteAccountBox.spec.tsx.snap +++ b/src/core/client/admin/routes/Login/__snapshots__/CompleteAccountBox.spec.tsx.snap @@ -32,7 +32,9 @@ exports[`renders correctly 1`] = `
- content +
+ content +
diff --git a/src/core/client/admin/routes/Login/views/AddEmailAddress/AddEmailAddress.tsx b/src/core/client/admin/routes/Login/views/AddEmailAddress/AddEmailAddress.tsx index 5daa97985..66566685e 100644 --- a/src/core/client/admin/routes/Login/views/AddEmailAddress/AddEmailAddress.tsx +++ b/src/core/client/admin/routes/Login/views/AddEmailAddress/AddEmailAddress.tsx @@ -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; -} - -const AddEmailAddress: FunctionComponent = 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 ( Add Email Address } > -
+ {({ handleSubmit, submitting, submitError }) => ( diff --git a/src/core/client/admin/routes/Login/views/AddEmailAddress/AddEmailAddressContainer.tsx b/src/core/client/admin/routes/Login/views/AddEmailAddress/AddEmailAddressContainer.tsx deleted file mode 100644 index 020e7957b..000000000 --- a/src/core/client/admin/routes/Login/views/AddEmailAddress/AddEmailAddressContainer.tsx +++ /dev/null @@ -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; -} - -class AddEmailAddressContainer extends Component { - 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 ; - } -} - -const enhanced = withMutation(SetEmailMutation)(AddEmailAddressContainer); -export default enhanced; diff --git a/src/core/client/admin/routes/Login/views/AddEmailAddress/index.ts b/src/core/client/admin/routes/Login/views/AddEmailAddress/index.ts index 638f761cc..f72df060c 100644 --- a/src/core/client/admin/routes/Login/views/AddEmailAddress/index.ts +++ b/src/core/client/admin/routes/Login/views/AddEmailAddress/index.ts @@ -1,4 +1 @@ -export { - default, - default as AddEmailAddressContainer, -} from "./AddEmailAddressContainer"; +export { default, default as AddEmailAddress } from "./AddEmailAddress"; diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.css b/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.css new file mode 100644 index 000000000..3f2f7b0b3 --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.css @@ -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); +} diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.spec.tsx b/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.spec.tsx new file mode 100644 index 000000000..043540aeb --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.spec.tsx @@ -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(Or); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.tsx b/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.tsx new file mode 100644 index 000000000..e1b3dcb69 --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/HorizontalSeparator.tsx @@ -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.children}
+
+); + +export default HorizontalSeparator; diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/LinkAccountContainer.tsx b/src/core/client/admin/routes/Login/views/LinkAccount/LinkAccountContainer.tsx new file mode 100644 index 000000000..2e6a250ae --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/LinkAccountContainer.tsx @@ -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 => { + const [local] = useLocal(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 = 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 ( + + Link Account + + } + > + + + {({ handleSubmit, submitting, submitError }) => ( + + + } + > + + The email {duplicateEmail} is already + associated with an account. If you would like to link these + enter your password. + + + {submitError && ( + + {submitError} + + )} + + {({ input, meta }) => ( + + + Password + + + + + + + )} + + + + + + + )} + + + + + + + + ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment LinkAccountContainer_viewer on User { + duplicateEmail + } + `, +})(LinkAccountContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/LinkAccountMutation.ts b/src/core/client/admin/routes/Login/views/LinkAccount/LinkAccountMutation.ts new file mode 100644 index 000000000..c1cac0659 --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/LinkAccountMutation.ts @@ -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; + +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; diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/OrSeparator.tsx b/src/core/client/admin/routes/Login/views/LinkAccount/OrSeparator.tsx new file mode 100644 index 000000000..2d16a9b83 --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/OrSeparator.tsx @@ -0,0 +1,12 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import HorizontalSeparator from "./HorizontalSeparator"; + +const OrSeparator: FunctionComponent = () => ( + + Or + +); + +export default OrSeparator; diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/__snapshots__/HorizontalSeparator.spec.tsx.snap b/src/core/client/admin/routes/Login/views/LinkAccount/__snapshots__/HorizontalSeparator.spec.tsx.snap new file mode 100644 index 000000000..a5a6fd47a --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/__snapshots__/HorizontalSeparator.spec.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + +
+
+ Or +
+
+`; diff --git a/src/core/client/admin/routes/Login/views/LinkAccount/index.ts b/src/core/client/admin/routes/Login/views/LinkAccount/index.ts new file mode 100644 index 000000000..2a1cd6635 --- /dev/null +++ b/src/core/client/admin/routes/Login/views/LinkAccount/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as LinkAccountContainer, +} from "./LinkAccountContainer"; diff --git a/src/core/client/admin/routes/Login/views/SignIn/OrSeparator.tsx b/src/core/client/admin/routes/Login/views/SignIn/OrSeparator.tsx index f27cb6565..2d16a9b83 100644 --- a/src/core/client/admin/routes/Login/views/SignIn/OrSeparator.tsx +++ b/src/core/client/admin/routes/Login/views/SignIn/OrSeparator.tsx @@ -4,7 +4,7 @@ import React, { FunctionComponent } from "react"; import HorizontalSeparator from "./HorizontalSeparator"; const OrSeparator: FunctionComponent = () => ( - + Or ); diff --git a/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap index 8e397abf7..0e1fc7292 100644 --- a/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap @@ -235,86 +235,90 @@ exports[`renders addEmailAddress view 1`] = `
-
-
-

- For your added security, we require users to add an email address to their accounts. -

- + For your added security, we require users to add an email address to their accounts. +

- + +
+ +
-
-
-
- + +
+ +
+
- -
-
+ +
diff --git a/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap index 48956fcb6..08fa5204b 100644 --- a/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap @@ -130,90 +130,92 @@ exports[`renders createPassword view 1`] = `
-
-
+ -

- To protect against unauthorized changes to your account, -we require users to create a password. -

-

- Must be at least 8 characters + To protect against unauthorized changes to your account, +we require users to create a password.

-
+ Password + +

+ Must be at least 8 characters +

+
-
-
+ +
+
- -
- + +
diff --git a/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap index 97b5ba0c6..a6f5d22df 100644 --- a/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap @@ -105,65 +105,67 @@ exports[`renders createUsername view 1`] = `
-
-
+ -

- Your username is an identifier that will appear on all of your comments. -

-

- You may use “_” and “.” Spaces not permitted. + Your username is an identifier that will appear on all of your comments.

- + +

+ You may use “_” and “.” Spaces not permitted. +

+
+ +
+
- -
-
+ +
diff --git a/src/core/client/admin/test/auth/__snapshots__/linkAccount.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/linkAccount.spec.tsx.snap new file mode 100644 index 000000000..24b56a79a --- /dev/null +++ b/src/core/client/admin/test/auth/__snapshots__/linkAccount.spec.tsx.snap @@ -0,0 +1,155 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders link account view 1`] = ` +
+
+
+
+
+

+ Finish Setting Up Your Account +

+

+ + Link Account + +

+
+
+
+
+
+
+

+ The email + + my@email.com + + is +already associated with an account. If you would like to +link these enter your password. +

+
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ Or +
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/core/client/admin/test/auth/accountCompletion.spec.tsx b/src/core/client/admin/test/auth/accountCompletion.spec.tsx index e90c346c6..1b27943f1 100644 --- a/src/core/client/admin/test/auth/accountCompletion.spec.tsx +++ b/src/core/client/admin/test/auth/accountCompletion.spec.tsx @@ -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({ - Query: { - settings: () => settings, - viewer: () => - pureMerge(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({ + Query: { + sites: () => siteConnection, + settings: () => settings, + viewer: () => + pureMerge(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({ 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({ 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(viewer, { + email: "", + username: "hans", + duplicateEmail: "my@email.com", + }), + }, + }, + }); + await act(async () => { + within(testRenderer.root).debug(); + await waitForElement(() => + within(testRenderer.root).getByTestID("linkAccount-container") + ); + }); +}); diff --git a/src/core/client/admin/test/auth/addEmailAddress.spec.tsx b/src/core/client/admin/test/auth/addEmailAddress.spec.tsx index 9e5854ae8..597dba466 100644 --- a/src/core/client/admin/test/auth/addEmailAddress.spec.tsx +++ b/src/core/client/admin/test/auth/addEmailAddress.spec.tsx @@ -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") + ); + }); +}); diff --git a/src/core/client/admin/test/auth/linkAccount.spec.tsx b/src/core/client/admin/test/auth/linkAccount.spec.tsx new file mode 100644 index 000000000..d8125df90 --- /dev/null +++ b/src/core/client/admin/test/auth/linkAccount.spec.tsx @@ -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(users.admins[0], { + email: "", +}); + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + replaceHistoryLocation("http://localhost/admin/login"); + + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + 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") + ); + }); +}); diff --git a/src/core/client/auth/App/AccountCompletion/AccountCompletionContainer.tsx b/src/core/client/auth/App/AccountCompletion/AccountCompletionContainer.tsx index e8c26e554..0a65bfdcc 100644 --- a/src/core/client/auth/App/AccountCompletion/AccountCompletionContainer.tsx +++ b/src/core/client/auth/App/AccountCompletion/AccountCompletionContainer.tsx @@ -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 } diff --git a/src/core/client/auth/App/App.tsx b/src/core/client/auth/App/App.tsx index cff05b8f6..a6f310c90 100644 --- a/src/core/client/auth/App/App.tsx +++ b/src/core/client/auth/App/App.tsx @@ -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["viewer"]; + viewer: PropTypesOf["viewer"] & + PropTypesOf["viewer"]; auth: PropTypesOf["auth"] & PropTypesOf["auth"]; } @@ -35,13 +38,15 @@ const render = ({ view, auth, viewer }: AppProps) => { case "SIGN_IN": return ; case "FORGOT_PASSWORD": - return ; + return ; case "CREATE_USERNAME": return ; case "CREATE_PASSWORD": return ; case "ADD_EMAIL_ADDRESS": return ; + case "LINK_ACCOUNT": + return ; default: throw new Error(`Unknown view ${view}`); } diff --git a/src/core/client/auth/App/AppContainer.tsx b/src/core/client/auth/App/AppContainer.tsx index 87a53c676..60ab0e607 100644 --- a/src/core/client/auth/App/AppContainer.tsx +++ b/src/core/client/auth/App/AppContainer.tsx @@ -61,6 +61,7 @@ const enhanced = withLocalStateContainer( fragment AppContainer_viewer on User { ...AccountCompletionContainer_viewer ...ForgotPasswordContainer_viewer + ...LinkAccountContainer_viewer } `, })(AppContainer) diff --git a/src/core/client/auth/local/initLocalState.spec.ts b/src/core/client/auth/local/initLocalState.spec.ts index 6f5c1cc68..ec0bb9c2f 100644 --- a/src/core/client/auth/local/initLocalState.spec.ts +++ b/src/core/client/auth/local/initLocalState.spec.ts @@ -14,6 +14,7 @@ let source: RecordSource; const context = { localStorage: window.localStorage, + sessionStorage: window.sessionStorage, }; beforeEach(() => { diff --git a/src/core/client/auth/local/initLocalState.ts b/src/core/client/auth/local/initLocalState.ts index 01fdb7fa6..2b23e4408 100644 --- a/src/core/client/auth/local/initLocalState.ts +++ b/src/core/client/auth/local/initLocalState.ts @@ -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); diff --git a/src/core/client/auth/local/local.graphql b/src/core/client/auth/local/local.graphql index e5e28807b..e928f7276 100644 --- a/src/core/client/auth/local/local.graphql +++ b/src/core/client/auth/local/local.graphql @@ -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 { diff --git a/src/core/client/auth/mutations/SetDuplicateEmailMutation.ts b/src/core/client/auth/mutations/SetDuplicateEmailMutation.ts new file mode 100644 index 000000000..d7a347ce9 --- /dev/null +++ b/src/core/client/auth/mutations/SetDuplicateEmailMutation.ts @@ -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; diff --git a/src/core/client/auth/mutations/SetViewMutation.ts b/src/core/client/auth/mutations/SetViewMutation.ts index bdf71e641..fc36a9b69 100644 --- a/src/core/client/auth/mutations/SetViewMutation.ts +++ b/src/core/client/auth/mutations/SetViewMutation.ts @@ -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. diff --git a/src/core/client/auth/mutations/index.ts b/src/core/client/auth/mutations/index.ts index 3ba2639a7..e65ac4cdd 100644 --- a/src/core/client/auth/mutations/index.ts +++ b/src/core/client/auth/mutations/index.ts @@ -1 +1,4 @@ export { default as SetViewMutation } from "./SetViewMutation"; +export { + default as SetDuplicateEmailMutation, +} from "./SetDuplicateEmailMutation"; diff --git a/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap index 1ebf51702..a79bab731 100644 --- a/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap @@ -864,155 +864,3 @@ GraphQL request (4:3) `; - -exports[`successfully sets email 1`] = ` -
-
-

- For your added security, we require users to add an email address to their accounts. -Your email address will be used to: -

-
    -
  • -
    - -
    -
    -
    - Receive updates regarding any changes to your account -(email address, username, password, etc.) -
    -
    -
  • -
  • -
    - -
    -
    -
    - Allow you to download your comments. -
    -
    -
  • -
  • -
    - -
    -
    -
    - Send comment notifications that you have chosen to receive. -
    -
    -
  • -
-
- -
- -
-
-
- -
- -
-
- -
-
-`; diff --git a/src/core/client/auth/test/__snapshots__/linkAccount.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/linkAccount.spec.tsx.snap new file mode 100644 index 000000000..84fe111b0 --- /dev/null +++ b/src/core/client/auth/test/__snapshots__/linkAccount.spec.tsx.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders link account view 1`] = ` +
+
+
+
+
+

+ Link Account +

+
+
+
+
+
+
+

+ The email + + my@email.com + + is +already associated with an account. If you would like to +link these enter your password. +

+
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ Or +
+
+ +
+
+
+
+
+`; diff --git a/src/core/client/auth/test/accountCompletion.spec.tsx b/src/core/client/auth/test/accountCompletion.spec.tsx index e7c8d9810..584dc2149 100644 --- a/src/core/client/auth/test/accountCompletion.spec.tsx +++ b/src/core/client/auth/test/accountCompletion.spec.tsx @@ -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; const accessToken = createAccessToken(); +const viewer = { id: "me", profiles: [] }; async function createTestRenderer( - customResolver: any = {}, - options: { muteNetworkErrors?: boolean; logNetwork?: boolean } = {} + params: CreateTestRendererParams = {} ) { - 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({ + 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" }], + }), }, }, }); diff --git a/src/core/client/auth/test/addEmailAddress.spec.tsx b/src/core/client/auth/test/addEmailAddress.spec.tsx index be5c2b0d6..e91d6ffc2 100644 --- a/src/core/client/auth/test/addEmailAddress.spec.tsx +++ b/src/core/client/auth/test/addEmailAddress.spec.tsx @@ -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); }); diff --git a/src/core/client/auth/test/linkAccount.spec.tsx b/src/core/client/auth/test/linkAccount.spec.tsx new file mode 100644 index 000000000..077cfa3e1 --- /dev/null +++ b/src/core/client/auth/test/linkAccount.spec.tsx @@ -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 = {} +) { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + 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") + ); + }); +}); diff --git a/src/core/client/auth/views/AddEmailAddress/AddEmailAddress.tsx b/src/core/client/auth/views/AddEmailAddress/AddEmailAddress.tsx index 272c1734a..8cb73f90b 100644 --- a/src/core/client/auth/views/AddEmailAddress/AddEmailAddress.tsx +++ b/src/core/client/auth/views/AddEmailAddress/AddEmailAddress.tsx @@ -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 = 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 }; } }, diff --git a/src/core/client/auth/views/LinkAccount/LinkAccountContainer.tsx b/src/core/client/auth/views/LinkAccount/LinkAccountContainer.tsx new file mode 100644 index 000000000..bdcdc5ec7 --- /dev/null +++ b/src/core/client/auth/views/LinkAccount/LinkAccountContainer.tsx @@ -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 => { + const [local] = useLocal(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 = 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 ( +
+ + + Link Account + + +
+ +
+ {({ handleSubmit, submitting, submitError }) => ( + + + } + > + + The email {duplicateEmail} is already + associated with an account. If you would like to link + these enter your password. + + + {submitError && ( + + {submitError} + + )} + + {({ input, meta }) => ( + + + Password + + + + + + + )} + + + + + +
+ )} + + + + + +
+
+
+ ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment LinkAccountContainer_viewer on User { + duplicateEmail + } + `, +})(LinkAccountContainer); + +export default enhanced; diff --git a/src/core/client/auth/views/LinkAccount/LinkAccountMutation.ts b/src/core/client/auth/views/LinkAccount/LinkAccountMutation.ts new file mode 100644 index 000000000..c1cac0659 --- /dev/null +++ b/src/core/client/auth/views/LinkAccount/LinkAccountMutation.ts @@ -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; + +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; diff --git a/src/core/client/auth/views/LinkAccount/index.ts b/src/core/client/auth/views/LinkAccount/index.ts new file mode 100644 index 000000000..2a1cd6635 --- /dev/null +++ b/src/core/client/auth/views/LinkAccount/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as LinkAccountContainer, +} from "./LinkAccountContainer"; diff --git a/src/core/client/framework/rest/index.ts b/src/core/client/framework/rest/index.ts index 4c30ce106..513e46f06 100644 --- a/src/core/client/framework/rest/index.ts +++ b/src/core/client/framework/rest/index.ts @@ -6,3 +6,4 @@ export { default as forgotPassword, ForgotPasswordInput, } from "./forgotPassword"; +export { default as linkAccount, LinkAccountInput } from "./linkAccount"; diff --git a/src/core/client/framework/rest/linkAccount.ts b/src/core/client/framework/rest/linkAccount.ts new file mode 100644 index 000000000..582e005ca --- /dev/null +++ b/src/core/client/framework/rest/linkAccount.ts @@ -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("/auth/link", { + method: "POST", + body: input, + }); +} diff --git a/src/core/client/framework/testHelpers/act.ts b/src/core/client/framework/testHelpers/act.ts index 738db0a9d..bfc0b3828 100644 --- a/src/core/client/framework/testHelpers/act.ts +++ b/src/core/client/framework/testHelpers/act.ts @@ -6,7 +6,10 @@ export default function act(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. diff --git a/src/core/client/stream/common/useHandleIncompleteAccount.ts b/src/core/client/stream/common/useHandleIncompleteAccount.ts new file mode 100644 index 000000000..341913fbe --- /dev/null +++ b/src/core/client/stream/common/useHandleIncompleteAccount.ts @@ -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; diff --git a/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewQuery.tsx b/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewQuery.tsx index 04f7374d7..a00da84fc 100644 --- a/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewQuery.tsx +++ b/src/core/client/stream/tabs/Comments/PermalinkView/PermalinkViewQuery.tsx @@ -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) => { const PermalinkViewQuery: FunctionComponent = ({ local: { commentID, storyID, storyURL }, }) => { + const handleIncompleteAccount = useHandleIncompleteAccount(); return ( query={graphql` @@ -77,7 +79,12 @@ const PermalinkViewQuery: FunctionComponent = ({ storyID, storyURL, }} - render={render} + render={data => { + if (handleIncompleteAccount(data)) { + return null; + } + return render(data); + }} /> ); }; diff --git a/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx b/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx index c6af87a21..9b4a17a30 100644 --- a/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/StreamQuery.tsx @@ -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 => { const { local: { storyID, storyURL, commentsTab }, } = props; + const handleIncompleteAccount = useHandleIncompleteAccount(); return ( <> @@ -88,6 +90,9 @@ const StreamQuery: FunctionComponent = props => { storyURL, }} render={data => { + if (handleIncompleteAccount(data)) { + return null; + } return render(data, commentsTab); }} /> diff --git a/src/core/client/stream/tabs/Profile/ProfileQuery.tsx b/src/core/client/stream/tabs/Profile/ProfileQuery.tsx index 66f3fee21..3e8e0cf21 100644 --- a/src/core/client/stream/tabs/Profile/ProfileQuery.tsx +++ b/src/core/client/stream/tabs/Profile/ProfileQuery.tsx @@ -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) => { const ProfileQuery: FunctionComponent = ({ local: { storyID, storyURL }, -}) => ( - - query={graphql` - query ProfileQuery($storyID: ID, $storyURL: String) { - story: stream(id: $storyID, url: $storyURL) { - ...ProfileContainer_story +}) => { + const handleIncompleteAccount = useHandleIncompleteAccount(); + return ( + + 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` diff --git a/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx b/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx index 1b41857d1..2addbe62b 100644 --- a/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx +++ b/src/core/client/stream/test/comments/permalink/permalinkView.spec.tsx @@ -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") ); diff --git a/src/core/server/app/handlers/api/auth/local/forgot.ts b/src/core/server/app/handlers/api/auth/local/forgot.ts index 5c5e1bc5b..62ecfecd3 100644 --- a/src/core/server/app/handlers/api/auth/local/forgot.ts +++ b/src/core/server/app/handlers/api/auth/local/forgot.ts @@ -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"); } diff --git a/src/core/server/app/handlers/api/auth/local/index.ts b/src/core/server/app/handlers/api/auth/local/index.ts index d07173fec..5f850a78a 100644 --- a/src/core/server/app/handlers/api/auth/local/index.ts +++ b/src/core/server/app/handlers/api/auth/local/index.ts @@ -4,6 +4,7 @@ import { RequestHandler } from "coral-server/types/express"; export * from "./forgot"; export * from "./signup"; +export * from "./link"; export type LogoutOptions = Pick; diff --git a/src/core/server/app/handlers/api/auth/local/link.ts b/src/core/server/app/handlers/api/auth/local/link.ts new file mode 100644 index 000000000..8f2a242ce --- /dev/null +++ b/src/core/server/app/handlers/api/auth/local/link.ts @@ -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); + } + }; +}; diff --git a/src/core/server/app/handlers/api/auth/local/signup.ts b/src/core/server/app/handlers/api/auth/local/signup.ts index ee7d6b2b7..173f54339 100644 --- a/src/core/server/app/handlers/api/auth/local/signup.ts +++ b/src/core/server/app/handlers/api/auth/local/signup.ts @@ -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"); } diff --git a/src/core/server/app/middleware/loggedIn.ts b/src/core/server/app/middleware/loggedIn.ts new file mode 100644 index 000000000..450663e20 --- /dev/null +++ b/src/core/server/app/middleware/loggedIn.ts @@ -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(); +}; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 10491ef55..d30a76448 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -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 +) { + 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 }); } } diff --git a/src/core/server/app/middleware/passport/strategies/facebook.ts b/src/core/server/app/middleware/passport/strategies/facebook.ts index 0222e03fb..d71aa5997 100644 --- a/src/core/server/app/middleware/passport/strategies/facebook.ts +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -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( diff --git a/src/core/server/app/middleware/passport/strategies/google.ts b/src/core/server/app/middleware/passport/strategies/google.ts index 8d2f81c65..a8621317d 100644 --- a/src/core/server/app/middleware/passport/strategies/google.ts +++ b/src/core/server/app/middleware/passport/strategies/google.ts @@ -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( diff --git a/src/core/server/app/middleware/passport/strategies/oauth2.ts b/src/core/server/app/middleware/passport/strategies/oauth2.ts index f88d9927b..f599625e3 100644 --- a/src/core/server/app/middleware/passport/strategies/oauth2.ts +++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts @@ -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, profile, - now + coral.now ); if (!user) { return done(null); diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.spec.ts b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.spec.ts index 8de1e2fa9..79929ecd2 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.spec.ts @@ -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() + ); }); diff --git a/src/core/server/app/router/api/auth.ts b/src/core/server/app/router/api/auth.ts index b7cff9513..e081c6874 100644 --- a/src/core/server/app/router/api/auth.ts +++ b/src/core/server/app/router/api/auth.ts @@ -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)); diff --git a/src/core/server/events/README.md b/src/core/server/events/README.md index 16da3e6e1..e165c39f5 100644 --- a/src/core/server/events/README.md +++ b/src/core/server/events/README.md @@ -1,3 +1,13 @@ + + +## Table of Contents + +- [events](#events) + - [Adding new events](#adding-new-events) + - [Adding new event listeners](#adding-new-event-listeners) + + + # events This is the events package for Coral. diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index 1dfa9a4d0..6bf856548 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -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] ) """ diff --git a/src/core/server/models/tenant/helpers.ts b/src/core/server/models/tenant/helpers.ts index 9087ec616..2d850bcb0 100644 --- a/src/core/server/models/tenant/helpers.ts +++ b/src/core/server/models/tenant/helpers.ts @@ -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, + integration: keyof GQLAuthIntegrations +) { + return tenant.auth.integrations[integration].enabled; +} + +export function linkUsersAvailable(tenant: Pick) { + return ( + hasEnabledAuthIntegration(tenant, "local") && + (hasEnabledAuthIntegration(tenant, "facebook") || + hasEnabledAuthIntegration(tenant, "google")) + ); +} + export function getWebhookEndpoint( tenant: Pick, endpointID: string diff --git a/src/core/server/models/user/helpers.ts b/src/core/server/models/user/helpers.ts index df870fbf4..734e809ef 100644 --- a/src/core/server/models/user/helpers.ts +++ b/src/core/server/models/user/helpers.ts @@ -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) { return roleIsStaff(user.role); } -export function getSSOProfile(user: Pick) { +export function getUserProfile( + user: Pick, + 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) { + return getUserProfile(user, "sso") as SSOProfile | null; } export function needsSSOUpdate( @@ -50,14 +56,7 @@ export function getLocalProfile( user: Pick, 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; } diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 5d96a9ea0..11dd4899a 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -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, diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index a48b4c9c4..8bc4085cf 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -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, - tenant: Pick, + tenant: Pick & { + auth: Pick; + }, 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, diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts index e33ece693..ad5f91ec5 100644 --- a/src/core/server/services/users/users.ts +++ b/src/core/server/services/users/users.ts @@ -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; + 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; +} diff --git a/src/locales/da/admin.ftl b/src/locales/da/admin.ftl index f19bc30c9..a55355c05 100644 --- a/src/locales/da/admin.ftl +++ b/src/locales/da/admin.ftl @@ -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 diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index ed3e277e0..fd8bb2a32 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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 { $email }, + 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 { $email } 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 User-Agent 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 { $email }, - 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 diff --git a/src/locales/en-US/auth.ftl b/src/locales/en-US/auth.ftl index 561d98a2c..13f6232e7 100644 --- a/src/locales/en-US/auth.ftl +++ b/src/locales/en-US/auth.ftl @@ -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 { $email } 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 diff --git a/src/locales/fr-FR/admin.ftl b/src/locales/fr-FR/admin.ftl index 20af5b0eb..4121c58bc 100755 --- a/src/locales/fr-FR/admin.ftl +++ b/src/locales/fr-FR/admin.ftl @@ -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 diff --git a/src/locales/pt-BR/admin.ftl b/src/locales/pt-BR/admin.ftl index d47b45afe..051764c7d 100644 --- a/src/locales/pt-BR/admin.ftl +++ b/src/locales/pt-BR/admin.ftl @@ -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 diff --git a/src/locales/sv/admin.ftl b/src/locales/sv/admin.ftl index 6b985c2d6..462ab00ba 100644 --- a/src/locales/sv/admin.ftl +++ b/src/locales/sv/admin.ftl @@ -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