[CORL-845] Account Linking (#2818)

* feat: added new linking backend

* feat: added duplicateEmail to hash

* fix: stored the duplicate email on the user

* feat: initial implmentation of account linking in auth

* test: fix unit tests

* fix+test: translations and tests added

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

* feat+test: account linking admin + more tests

* feat: Handle incomplete accounts

* chore: add some comments

* feat: expose duplicateEmail through graphql and impl for stream

* feat: admin to use duplicateEmail from graphql

* fix: no need to validate password for account linking

* fix: dont validate password

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

* chore: log to console when encountering incomplete account

* chore: adjust comment

* chore: simplify + add comments

* chore: wording

* chore: comments

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Wyatt Johnson
2020-02-25 15:46:32 -05:00
committed by GitHub
co-authored by Vinh Kim Gardner
parent 7497e33046
commit dcb2a10e72
80 changed files with 2414 additions and 710 deletions
@@ -235,86 +235,90 @@ exports[`renders addEmailAddress view 1`] = `
<div
className="CompleteAccountBox-main"
>
<form
autoComplete="off"
onSubmit={[Function]}
<div
data-testid="addEmailAddress-container"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
<form
autoComplete="off"
onSubmit={[Function]}
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
For your added security, we require users to add an email address to their accounts.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="email"
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Email Address
</label>
For your added security, we require users to add an email address to their accounts.
</p>
<div
className="TextField-root TextField-fullWidth"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Email Address"
type="text"
value=""
/>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="email"
>
Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="email"
name="email"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Email Address"
type="text"
value=""
/>
</div>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="confirmEmail"
>
Confirm Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="confirmEmail"
name="confirmEmail"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Confirm Email Address"
type="text"
value=""
/>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="confirmEmail"
>
Confirm Email Address
</label>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="confirmEmail"
name="confirmEmail"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Confirm Email Address"
type="text"
value=""
/>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Add Email Address
</button>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Add Email Address
</button>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
@@ -130,90 +130,92 @@ exports[`renders createPassword view 1`] = `
<div
className="CompleteAccountBox-main"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
<div>
<form
autoComplete="off"
onSubmit={[Function]}
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
To protect against unauthorized changes to your account,
we require users to create a password.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Must be at least 8 characters
To protect against unauthorized changes to your account,
we require users to create a password.
</p>
<div
className="PasswordField-fullWidth PasswordField-root"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
>
Must be at least 8 characters
</p>
<div
className="PasswordField-wrapper"
className="PasswordField-fullWidth PasswordField-root"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Show password"
className="PasswordField-wrapper"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Show password"
>
visibility
</i>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Password
</button>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Password
</button>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
@@ -105,65 +105,67 @@ exports[`renders createUsername view 1`] = `
<div
className="CompleteAccountBox-main"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
<div>
<form
autoComplete="off"
onSubmit={[Function]}
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="username"
>
Username
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
You may use “_” and “.” Spaces not permitted.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="TextField-root TextField-fullWidth"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Username"
type="text"
value=""
/>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="username"
>
Username
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
>
You may use “_” and “.” Spaces not permitted.
</p>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
disabled={false}
id="username"
name="username"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Username"
type="text"
value=""
/>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Username
</button>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Create Username
</button>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,155 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders link account view 1`] = `
<div>
<div
data-testid="completeAccountBox"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyCenter"
>
<div
className="CompleteAccountBox-container"
>
<div
className="CompleteAccountBox-header"
>
<h1
className="Box-root Typography-root Typography-heading3 Typography-colorTextLight Typography-alignCenter CompleteAccountBox-heading3"
>
Finish Setting Up Your Account
</h1>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextLight Typography-alignCenter"
>
<span>
Link Account
</span>
</h1>
</div>
<div
className="CompleteAccountBox-main"
>
<div
data-testid="linkAccount-container"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-3"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
The email
<strong>
my@email.com
</strong>
is
already associated with an account. If you would like to
link these enter your password.
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide password"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Link Account
</button>
</div>
</form>
<div
className="Box-root Flex-root HorizontalSeparator-root Flex-flex Flex-justifyCenter Flex-alignCenter"
>
<hr
className="HorizontalSeparator-hr"
/>
<div
className="HorizontalSeparator-text"
>
Or
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorRegular Button-variantFilled Button-fullWidth"
data-color="regular"
data-variant="filled"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Use a different email address
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -11,7 +11,12 @@ import {
} from "coral-framework/testHelpers";
import create from "../create";
import { emptyModerationQueues, settings, users } from "../fixtures";
import {
emptyModerationQueues,
settings,
siteConnection,
users,
} from "../fixtures";
const viewer = users.admins[0];
@@ -20,45 +25,49 @@ async function createTestRenderer(
) {
replaceHistoryLocation("http://localhost/admin/login");
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () =>
pureMerge<typeof viewer>(viewer, {
email: "",
username: "",
profiles: [],
}),
moderationQueues: () => emptyModerationQueues,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("SIGN_IN", "authView");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return act(() => {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
sites: () => siteConnection,
settings: () => settings,
viewer: () =>
pureMerge<typeof viewer>(viewer, {
email: "",
username: "",
profiles: [],
}),
moderationQueues: () => emptyModerationQueues,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("SIGN_IN", "authView");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return {
context,
testRenderer,
root: testRenderer.root,
};
return {
context,
testRenderer,
};
});
}
it("renders addEmailAddress view", async () => {
const { root } = await createTestRenderer();
await waitForElement(() => within(root).queryByText("Add Email Address"));
const { testRenderer } = await createTestRenderer();
await waitForElement(() =>
within(testRenderer.root).queryByText("Add Email Address")
);
});
it("renders createUsername view", async () => {
const { root } = await createTestRenderer({
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
viewer: () =>
@@ -70,11 +79,13 @@ it("renders createUsername view", async () => {
},
}),
});
await waitForElement(() => within(root).queryByText("Create Username"));
await waitForElement(() =>
within(testRenderer.root).queryByText("Create Username")
);
});
it("renders createPassword view", async () => {
const { root } = await createTestRenderer({
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
@@ -88,7 +99,9 @@ it("renders createPassword view", async () => {
},
}),
});
await waitForElement(() => within(root).queryByText("Create Password"));
await waitForElement(() =>
within(testRenderer.root).queryByText("Create Password")
);
});
it("do not render createPassword view when local auth is disabled", async () => {
@@ -145,3 +158,24 @@ it("complete account", async () => {
);
});
});
it("renders account linking view", async () => {
const { testRenderer } = await createTestRenderer({
resolvers: {
Query: {
viewer: () =>
pureMerge<typeof viewer>(viewer, {
email: "",
username: "hans",
duplicateEmail: "my@email.com",
}),
},
},
});
await act(async () => {
within(testRenderer.root).debug();
await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
});
});
@@ -1,4 +1,8 @@
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { pureMerge } from "coral-common/utils";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { GQLResolver } from "coral-framework/schema";
import {
act,
@@ -229,3 +233,46 @@ it("successfully sets email", async () => {
expect(toJSON(form)).toMatchSnapshot();
expect(resolvers.Mutation!.setEmail!.called).toBe(true);
});
it("switch to link account", async () => {
const email = "hans@test.com";
const setEmail = sinon.stub().callsFake((_: any, data: any) => {
throw new InvalidRequestError({ code: ERROR_CODES.DUPLICATE_EMAIL });
});
const {
testRenderer,
form,
emailAddressField,
confirmEmailAddressField,
} = await createTestRenderer({
resolvers: {
Mutation: {
setEmail,
},
},
muteNetworkErrors: true,
});
const submitButton = form.find(
i => i.type === "button" && i.props.type === "submit"
);
act(() => emailAddressField.props.onChange({ target: { value: email } }));
act(() =>
confirmEmailAddressField.props.onChange({
target: { value: email },
})
);
act(() => {
form.props.onSubmit();
});
expect(emailAddressField.props.disabled).toBe(true);
expect(confirmEmailAddressField.props.disabled).toBe(true);
expect(submitButton.props.disabled).toBe(true);
await act(async () => {
await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
});
});
@@ -0,0 +1,156 @@
import sinon from "sinon";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createAccessToken,
createResolversStub,
CreateTestRendererParams,
replaceHistoryLocation,
wait,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "../create";
import { settings, users } from "../fixtures";
const viewer = pureMerge<typeof users["admins"][0]>(users.admins[0], {
email: "",
});
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
replaceHistoryLocation("http://localhost/admin/login");
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
viewer: () => viewer,
settings: () => settings,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("LINK_ACCOUNT", "authView");
localRecord.setValue("my@email.com", "authDuplicateEmail");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("linkAccount-container")
);
const form = within(container).queryByType("form")!;
const passwordField = within(form).getByLabelText("Password");
return {
context,
testRenderer,
form,
container,
passwordField,
};
}
it("renders link account view", async () => {
const { testRenderer } = await createTestRenderer();
expect(testRenderer.toJSON()).toMatchSnapshot();
expect(await within(testRenderer.root).axe()).toHaveNoViolations();
});
it("checks for required password", async () => {
const { form, container } = await createTestRenderer();
act(() => {
form.props.onSubmit();
});
within(container).getByText("This field is required", { exact: false });
});
it("performs account linking", async () => {
const { form, passwordField, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/auth/link", {
method: "POST",
body: {
email: "my@email.com",
password: "testtest",
},
})
.once()
.returns({
token: createAccessToken(),
});
act(() => {
passwordField.props.onChange("testtest");
});
act(() => {
form.props.onSubmit();
});
// Look for visual cues for form submission progress.
expect(passwordField.props.disabled).toBe(true);
await act(async () => {
await wait(() => expect(passwordField.props.disabled).toBe(false));
});
restMock.verify();
});
it("shows server error", async () => {
const { form, passwordField, context } = await createTestRenderer();
const error = new Error("Server Error");
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/auth/link", {
method: "POST",
body: {
email: "my@email.com",
password: "testtest",
},
})
.once()
.throws(error);
act(() => {
passwordField.props.onChange("testtest");
});
act(() => {
form.props.onSubmit();
});
// Look for visual cues for form submission progress.
expect(passwordField.props.disabled).toBe(true);
await act(async () => {
await wait(() => expect(passwordField.props.disabled).toBe(false));
});
within(form).getByText("Server Error", { exact: false });
restMock.verify();
});
it("change email view", async () => {
const { testRenderer, container } = await createTestRenderer();
const button = within(container).getByText("Use a different Email", {
exact: false,
});
act(() => {
button.props.onClick();
});
await act(async () => {
await waitForElement(() =>
within(testRenderer.root).getByTestID("addEmailAddress-container")
);
});
});