[CORL-220] Change email address (#2461)

* change email form

* add update email mutation

* validate new email addresses

* add email verification callout

* add translation strings

* fix submit button logic

* rename model methods

* style email verifcation box

* resend email verificatoin button

* show success message on email resend

* update user profile email

* fix duplicate import

* add change email spec

* fix profile spacing

* update snapshots

* update snaps

* add preventSubmit function to email change component

* update business logic for profile updating

* update logic for when users can update email or username

* check for less specific duplicate email error

* prevent sso-only users from editing email

* only allow email and username edit if enabeld in local profile

* use generic server error for cannot update profile

* remove merge conflict

* fix tests

* extract logic to get auth integrations

* fix merge error
This commit is contained in:
Tessa Thornton
2019-08-20 12:28:14 -04:00
committed by GitHub
parent 0f02b05689
commit ee93267d97
18 changed files with 1016 additions and 69 deletions
@@ -0,0 +1,36 @@
.callOut {
max-width: 500px;
}
.footer {
margin-top: var(--spacing-4);
}
.errorIcon {
padding-right: var(--spacing-2);
padding-top: var(--spacing-1);
}
.successMessage {
background-color: var(--palette-primary-lightest);
border-color: var(--palette-primary-light);
color: var(--palette-text-primary);
padding: var(--spacing-2);
box-sizing: border-box;
border-width: 1px;
border-style: solid;
}
.closeButton {
float: none;
position: static;
}
.currentEmail {
color: var(--palette-grey-dark);
}
.resendButton {
padding-left: 0;
padding-right: 0;
}
@@ -0,0 +1,391 @@
import { FORM_ERROR, FormApi, FormState } from "final-form";
import { Localized } from "fluent-react/compat";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { Field, Form } from "react-final-form";
import { Environment } from "relay-runtime";
import { PasswordField } from "coral-framework/components";
import getAuthenticationIntegrations from "coral-framework/helpers/getAuthenticationIntegrations";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form";
import { createFetch, useFetch } from "coral-framework/lib/relay";
import {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import {
composeValidators,
required,
validateEmail,
} from "coral-framework/lib/validation";
import {
Button,
ButtonIcon,
CallOut,
Flex,
FormField,
HorizontalGutter,
Icon,
InputLabel,
TextField,
Typography,
} from "coral-ui/components";
import { ChangeEmailContainer_settings as SettingsData } from "coral-stream/__generated__/ChangeEmailContainer_settings.graphql";
import { ChangeEmailContainer_viewer as ViewerData } from "coral-stream/__generated__/ChangeEmailContainer_viewer.graphql";
import UpdateEmailMutation from "./UpdateEmailMutation";
import styles from "./ChangeEmailContainer.css";
const fetcher = createFetch(
"resendConfirmation",
(environment: Environment, variables, context) => {
return context.rest.fetch<void>("/account/confirm", {
method: "POST",
});
}
);
interface Props {
viewer: ViewerData;
settings: SettingsData;
}
interface FormProps {
email: string;
password: string;
}
const changeEmailContainer: FunctionComponent<Props> = ({
viewer,
settings,
}) => {
const updateEmail = useMutation(UpdateEmailMutation);
const [showEditForm, setShowEditForm] = useState(false);
const [emailUpdated, setEmailUpdated] = useState(false);
const [confirmationResent, setConfirmationResent] = useState(false);
const makeFetchCall = useFetch(fetcher);
const resend = useCallback(async () => {
await makeFetchCall();
setConfirmationResent(true);
}, [fetcher]);
const toggleEditForm = useCallback(() => {
setShowEditForm(!showEditForm);
}, [setShowEditForm, showEditForm]);
const onSubmit = useCallback(
async (input: FormProps, form: FormApi) => {
try {
await updateEmail({
email: input.email,
password: input.password,
});
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return {
[FORM_ERROR]: err.message,
};
}
form.reset();
setShowEditForm(false);
setEmailUpdated(true);
return;
},
[updateEmail]
);
const canChangeEmail = useMemo(() => {
if (
!viewer.profiles.find(profile => profile.__typename === "LocalProfile")
) {
return false;
}
const enabled = getAuthenticationIntegrations(settings.auth, "stream");
return (
enabled.includes("local") ||
!(enabled.length === 1 && enabled[0] === "sso")
);
}, [viewer, settings]);
const preventSubmit = (
formState: Pick<
FormState,
| "pristine"
| "hasSubmitErrors"
| "hasValidationErrors"
| "dirtySinceLastSubmit"
>
) => {
const {
pristine,
hasValidationErrors,
hasSubmitErrors,
dirtySinceLastSubmit,
} = formState;
return (
pristine ||
hasValidationErrors ||
(hasSubmitErrors && !dirtySinceLastSubmit)
);
};
return (
<HorizontalGutter spacing={5} data-testid="profile-changeEmail">
{!showEditForm && (
<Flex alignItems="center">
<Typography>{viewer.email}</Typography>{" "}
{!viewer.emailVerified && (
<Localized id="profile-changeEmail-unverified">
<Typography color="textSecondary">(Unverified)</Typography>
</Localized>
)}
{canChangeEmail && (
<Localized id="profile-changeEmail-edit">
<Button size="small" color="primary" onClick={toggleEditForm}>
Edit
</Button>
</Localized>
)}
</Flex>
)}
{!viewer.emailVerified && emailUpdated && !showEditForm && (
<CallOut>
<Flex itemGutter>
<div>
<Icon size="lg">email</Icon>
</div>
<div>
<Localized id="profile-changeEmail-please-verify">
<Typography variant="heading3" gutterBottom>
Verify your email address
</Typography>
</Localized>
<Localized
id="profile-changeEmail-please-verify-details"
$email={viewer.email}
>
<Typography>
An email has been sent to {viewer.email} to verify your
account. You must verify your new email address before it can
be used for signing into your account or for email
notifications.
</Typography>
</Localized>
<Localized id="profile-changeEmail-resend">
<Button
onClick={resend}
className={styles.resendButton}
color="primary"
>
Resend verification
</Button>
</Localized>
</div>
</Flex>
</CallOut>
)}
{confirmationResent && (
<CallOut fullWidth color="primary">
<Localized id="profile-changeEmail-resent">
<Typography>Your confirmation email has been re-sent.</Typography>
</Localized>
</CallOut>
)}
{showEditForm && (
<CallOut className={styles.callOut} color="primary">
<HorizontalGutter spacing={4}>
<div>
<Localized id="profile-changeEmail-heading">
<Typography variant="heading2" gutterBottom>
Edit your email address
</Typography>
</Localized>
<Localized id="profile-changeEmail-desc">
<Typography>
Change the email address used for signing in and for receiving
communication about your account.
</Typography>
</Localized>
</div>
<div>
<Localized id="profile-changeEmail-current">
<Typography
className={styles.currentEmail}
variant="bodyCopyBold"
>
Current email
</Typography>
</Localized>
<Typography variant="heading2">{viewer.email}</Typography>
</div>
<Form onSubmit={onSubmit}>
{({
handleSubmit,
submitError,
invalid,
submitting,
...formProps
}) => (
<form
onSubmit={handleSubmit}
data-testid="profile-changeEmail-form"
>
<HorizontalGutter spacing={4}>
<FormField>
<HorizontalGutter>
<Localized id="profile-changeEmail-newEmail-label">
<InputLabel htmlFor="profile-changeEmail-Email">
New email address
</InputLabel>
</Localized>
<Field
name="email"
validate={composeValidators(required, validateEmail)}
>
{({ input, meta }) => (
<>
<TextField
{...input}
fullWidth
id="profile-changeEmail-Email"
/>
<ValidationMessage meta={meta} />
</>
)}
</Field>
</HorizontalGutter>
</FormField>
<FormField>
<HorizontalGutter>
<Field
name="password"
validate={composeValidators(required)}
>
{({ input, meta }) => (
<FormField>
<Localized id="profile-changeEmail-password">
<InputLabel htmlFor={input.name}>
Password
</InputLabel>
</Localized>
<Localized
id="profile-changeEmail-password-input"
attrs={{ placeholder: true }}
>
<PasswordField
id={input.name}
placeholder="Password"
color={colorFromMeta(meta)}
disabled={submitting}
fullWidth
{...input}
/>
</Localized>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
</HorizontalGutter>
</FormField>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
</HorizontalGutter>
<Flex justifyContent="flex-end" className={styles.footer}>
<Localized id="profile-changeEmail-cancel">
<Button type="button" onClick={toggleEditForm}>
Cancel
</Button>
</Localized>
<Localized id="profile-changeEmail-submit">
<Button
variant={
preventSubmit(formProps) ? "outlined" : "filled"
}
type="submit"
color={preventSubmit(formProps) ? "regular" : "primary"}
disabled={preventSubmit(formProps)}
>
<ButtonIcon>save</ButtonIcon>
<span>Save</span>
</Button>
</Localized>
</Flex>
</form>
)}
</Form>
</HorizontalGutter>
</CallOut>
)}
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment ChangeEmailContainer_viewer on User {
email
emailVerified
profiles {
__typename
}
}
`,
settings: graphql`
fragment ChangeEmailContainer_settings on Settings {
auth {
integrations {
local {
enabled
targetFilter {
stream
}
}
google {
enabled
targetFilter {
stream
}
}
oidc {
enabled
targetFilter {
stream
}
}
sso {
enabled
targetFilter {
stream
}
}
facebook {
enabled
targetFilter {
stream
}
}
}
}
}
`,
})(changeEmailContainer);
export default enhanced;
@@ -0,0 +1,46 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateEmailMutation as MutationTypes } from "coral-stream/__generated__/UpdateEmailMutation.graphql";
let clientMutationId = 0;
const UpdateEmailMutation = createMutation(
"updateEmail",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateEmailMutation($input: UpdateEmailInput!) {
updateEmail(input: $input) {
clientMutationId
user {
email
emailVerified
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticResponse: {
updateEmail: {
clientMutationId: (clientMutationId++).toString(),
user: {
email: input.email,
emailVerified: false,
},
},
},
})
);
export default UpdateEmailMutation;
@@ -0,0 +1,4 @@
export {
default,
default as ChangeEmailContainer,
} from "./ChangeEmailContainer";
@@ -11,6 +11,7 @@ import { Field, Form } from "react-final-form";
import { ALLOWED_USERNAME_CHANGE_FREQUENCY } from "coral-common/constants";
import { reduceSeconds, UNIT } from "coral-common/helpers/i18n";
import getAuthenticationIntegrations from "coral-framework/helpers/getAuthenticationIntegrations";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { ValidationMessage } from "coral-framework/lib/form";
import {
@@ -24,6 +25,7 @@ import {
validateUsername,
validateUsernameEquals,
} from "coral-framework/lib/validation";
import { ChangeUsernameContainer_settings as SettingsData } from "coral-stream/__generated__/ChangeUsernameContainer_settings.graphql";
import { ChangeUsernameContainer_viewer as ViewerData } from "coral-stream/__generated__/ChangeUsernameContainer_viewer.graphql";
import {
Box,
@@ -50,6 +52,7 @@ const FREQUENCYSCALED = reduceSeconds(ALLOWED_USERNAME_CHANGE_FREQUENCY, [
interface Props {
viewer: ViewerData;
settings: SettingsData;
}
interface FormProps {
@@ -57,7 +60,10 @@ interface FormProps {
usernameConfirm: string;
}
const ChangeUsernameContainer: FunctionComponent<Props> = ({ viewer }) => {
const ChangeUsernameContainer: FunctionComponent<Props> = ({
viewer,
settings,
}) => {
const [showEditForm, setShowEditForm] = useState(false);
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
const toggleEditForm = useCallback(() => {
@@ -69,6 +75,20 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({ viewer }) => {
setShowEditForm,
]);
const canChangeLocalAuth = useMemo(() => {
if (
!viewer.profiles.find(profile => profile.__typename === "LocalProfile")
) {
return false;
}
const enabled = getAuthenticationIntegrations(settings.auth, "stream");
return (
enabled.includes("local") ||
!(enabled.length === 1 && enabled[0] === "sso")
);
}, [viewer, settings]);
const canChangeUsername = useMemo(() => {
const { username } = viewer.status;
if (username && username.history.length > 1) {
@@ -148,11 +168,13 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({ viewer }) => {
{!showEditForm && (
<Flex alignItems="baseline">
<Typography variant="header2">{viewer.username}</Typography>
<Localized id="profile-changeUsername-edit">
<Button size="small" color="primary" onClick={toggleEditForm}>
edit
</Button>
</Localized>
{canChangeLocalAuth && (
<Localized id="profile-changeUsername-edit">
<Button size="small" color="primary" onClick={toggleEditForm}>
edit
</Button>
</Localized>
)}
</Flex>
)}
{showEditForm && (
@@ -337,6 +359,9 @@ const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment ChangeUsernameContainer_viewer on User {
username
profiles {
__typename
}
status {
username {
history {
@@ -347,6 +372,44 @@ const enhanced = withFragmentContainer<Props>({
}
}
`,
settings: graphql`
fragment ChangeUsernameContainer_settings on Settings {
auth {
integrations {
local {
enabled
targetFilter {
stream
}
}
google {
enabled
targetFilter {
stream
}
}
oidc {
enabled
targetFilter {
stream
}
}
sso {
enabled
targetFilter {
stream
}
}
facebook {
enabled
targetFilter {
stream
}
}
}
}
}
`,
})(ChangeUsernameContainer);
export default enhanced;
@@ -1,3 +1,4 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, useLocal } from "coral-framework/lib/relay";
@@ -11,8 +12,8 @@ import {
TabContent,
TabPane,
} from "coral-ui/components";
import { Localized } from "fluent-react/compat";
import ChangeEmailContainer from "./ChangeEmail";
import ChangeUsernameContainer from "./ChangeUsername";
import CommentHistoryContainer from "./CommentHistory";
import SettingsContainer from "./Settings";
@@ -22,9 +23,13 @@ export interface ProfileProps {
viewer: PropTypesOf<typeof UserBoxContainer>["viewer"] &
PropTypesOf<typeof CommentHistoryContainer>["viewer"] &
PropTypesOf<typeof SettingsContainer>["viewer"] &
PropTypesOf<typeof ChangeUsernameContainer>["viewer"];
PropTypesOf<typeof ChangeUsernameContainer>["viewer"] &
PropTypesOf<typeof ChangeEmailContainer>["viewer"] &
PropTypesOf<typeof SettingsContainer>["viewer"];
settings: PropTypesOf<typeof UserBoxContainer>["settings"] &
PropTypesOf<typeof SettingsContainer>["settings"];
PropTypesOf<typeof ChangeEmailContainer>["settings"] &
PropTypesOf<typeof SettingsContainer>["settings"] &
PropTypesOf<typeof ChangeUsernameContainer>["settings"];
}
const Profile: FunctionComponent<ProfileProps> = props => {
@@ -39,7 +44,13 @@ const Profile: FunctionComponent<ProfileProps> = props => {
);
return (
<HorizontalGutter spacing={5}>
<ChangeUsernameContainer viewer={props.viewer} />
<HorizontalGutter spacing={2}>
<ChangeUsernameContainer
settings={props.settings}
viewer={props.viewer}
/>
<ChangeEmailContainer settings={props.settings} viewer={props.viewer} />
</HorizontalGutter>
<TabBar
variant="secondary"
activeTab={local.profileTab}
@@ -37,11 +37,14 @@ const enhanced = withFragmentContainer<ProfileContainerProps>({
...CommentHistoryContainer_viewer
...SettingsContainer_viewer
...ChangeUsernameContainer_viewer
...ChangeEmailContainer_viewer
}
`,
settings: graphql`
fragment ProfileContainer_settings on Settings {
...UserBoxContainer_settings
...ChangeEmailContainer_settings
...ChangeUsernameContainer_settings
...SettingsContainer_settings
}
`,
+15
View File
@@ -113,6 +113,8 @@ export const settingsWithoutLocalAuth = createFixture<GQLSettings>(
export const baseUser = createFixture<GQLUser>({
createdAt: "2018-02-06T18:24:00.000Z",
id: "base-user",
role: GQLUSER_ROLE.COMMENTER,
status: {
current: [GQLUSER_STATUS.ACTIVE],
username: {
@@ -130,6 +132,11 @@ export const baseUser = createFixture<GQLUser>({
},
},
ignoreable: true,
profiles: [
{
__typename: "LocalProfile",
},
],
});
const yesterday = new Date();
@@ -184,6 +191,14 @@ export const userWithChangedUsername = createFixture<GQLUser>(
baseUser
);
export const userWithEmail = createFixture<GQLUser>(
{
id: "email-user",
email: "used-email@email.com",
},
baseUser
);
export const commenters = createFixtures<GQLUser>(
[
{
@@ -66,29 +66,65 @@ exports[`renders the empty settings pane 1`] = `
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
data-testid="profile-changeUsername"
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-2"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignBaseline"
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
data-testid="profile-changeUsername"
>
<h1
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
<div
className="Box-root Flex-root Flex-flex Flex-alignBaseline"
>
Passivo
</h1>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
<h1
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
>
Passivo
</h1>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
edit
</button>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
data-testid="profile-changeEmail"
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
edit
</button>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
/>
<span>
 
</span>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextSecondary"
>
(Unverified)
</p>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Edit
</button>
</div>
</div>
</div>
<ul
@@ -0,0 +1,137 @@
import { ReactTestRenderer } from "react-test-renderer";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createResolversStub,
CreateTestRendererParams,
within,
} from "coral-framework/testHelpers";
import { baseUser, settings, stories } from "../fixtures";
import create from "./create";
const story = stories[0];
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () => baseUser,
story: () => story,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("SETTINGS", "profileTab");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return {
testRenderer,
context,
};
}
describe("change email form", () => {
let testRenderer: ReactTestRenderer;
beforeEach(async () => {
const setup = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
viewer: () => baseUser,
},
Mutation: {
updateEmail: ({ variables }) => {
expectAndFail(variables).toMatchObject({
email: "updated_email@test.com",
});
return {
user: {
...baseUser,
email: "updated_email@test.com",
},
};
},
},
}),
});
testRenderer = setup.testRenderer;
});
it("ensures email field is required", async () => {
const changeEmail = within(testRenderer.root).getByTestID(
"profile-changeEmail"
);
const editButton = within(changeEmail).getByText("Edit");
act(() => {
editButton.props.onClick();
});
const form = within(changeEmail).getByType("form");
act(() => {
form.props.onSubmit();
});
within(changeEmail).getAllByText("This field is required", {
exact: false,
});
const button = within(changeEmail).getByText("Save");
expect(button.props.disabled).toBeTruthy();
});
it("ensures password field is required", async () => {
const changeEmail = within(testRenderer.root).getByTestID(
"profile-changeEmail"
);
const editButton = within(changeEmail).getByText("Edit");
act(() => {
editButton.props.onClick();
});
const form = within(changeEmail).getByType("form");
const emailInput = within(changeEmail).getByLabelText("New email address", {
exact: false,
});
act(() => {
emailInput.props.onChange("test@test.com");
form.props.onSubmit();
});
within(changeEmail).getByText("This field is required", {
exact: false,
});
const button = within(changeEmail).getByText("Save");
expect(button.props.disabled).toBeTruthy();
});
it("updates email if fields are valid", async () => {
const changeEmail = within(testRenderer.root).getByTestID(
"profile-changeEmail"
);
const editButton = within(changeEmail).getByText("Edit");
act(() => {
editButton.props.onClick();
});
const form = within(changeEmail).getByType("form");
const emailInput = within(changeEmail).getByLabelText("New email address", {
exact: false,
});
const password = within(changeEmail).getByLabelText("Password");
await act(async () => {
emailInput.props.onChange("updated_email@test.com");
password.props.onChange("test");
await form.props.onSubmit();
});
within(changeEmail).getAllByText("updated_email@test.com", {
exact: false,
});
});
});