[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
+1 -1
View File
@@ -24217,7 +24217,7 @@
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
}
@@ -0,0 +1,33 @@
import { GQLAuthIntegrations } from "../schema/__generated__/types";
interface Integration {
enabled: boolean;
targetFilter: {
admin?: boolean;
stream?: boolean;
};
}
interface AuthSettings {
integrations: {
[index: string]: Integration;
};
}
export default function enabledAuthenticationIntegrations(
auth: AuthSettings,
target?: "stream" | "admin"
): string[] {
if (auth.integrations) {
return Object.keys(auth.integrations).filter(
(key: keyof GQLAuthIntegrations) => {
const { targetFilter, enabled } = auth.integrations[key];
if (target) {
return enabled && targetFilter[target];
}
return enabled && targetFilter.admin && targetFilter.stream;
}
);
}
return [];
}
@@ -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,
});
});
});
+14 -1
View File
@@ -17,6 +17,7 @@ import {
suspend,
updateAvatar,
updateEmail,
updateEmailByID,
updatePassword,
updateRole,
updateUsername,
@@ -38,6 +39,7 @@ import {
GQLSetPasswordInput,
GQLSetUsernameInput,
GQLSuspendUserInput,
GQLUpdateEmailInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserEmailInput,
@@ -140,7 +142,18 @@ export const Users = (ctx: TenantContext) => ({
ctx.user!
),
updateUserEmail: async (input: GQLUpdateUserEmailInput) =>
updateEmail(ctx.mongo, ctx.tenant, input.userID, input.email),
updateEmailByID(ctx.mongo, ctx.tenant, input.userID, input.email),
updateEmail: async (input: GQLUpdateEmailInput) =>
updateEmail(
ctx.mongo,
ctx.tenant,
ctx.mailerQueue,
ctx.config,
ctx.signingConfig!,
ctx.user!,
input.email,
input.password
),
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
@@ -145,6 +145,10 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.updateUserUsername(input),
clientMutationId: input.clientMutationId,
}),
updateEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.updateEmail(input),
clientMutationId: input.clientMutationId,
}),
updateUserEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.updateUserEmail(input),
clientMutationId: input.clientMutationId,
@@ -4189,6 +4189,38 @@ type UpdateUserUsernamePayload {
clientMutationId: String!
}
##################
# updateEmail
##################
input UpdateEmailInput {
"""
email is the email address to set for the User.
"""
email: String!
"""
password is the users password.
"""
password: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateEmailPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# updateUserEmail
##################
@@ -4704,6 +4736,12 @@ type Mutation {
input: UpdateUserUsernameInput!
): UpdateUserUsernamePayload! @auth(roles: [ADMIN])
"""
updateEmail allows administrators to update a given User's email address
to the one provided.
"""
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload! @auth
"""
updateUserEmail allows administrators to update a given User's email address
to the one provided.
+35 -35
View File
@@ -889,53 +889,53 @@ export async function setUserEmail(
* @param tenantID the Tenant ID of the Tenant where the User exists
* @param id the User ID that we are updating
* @param emailAddress email address that we are setting on the User
* @param emailVerified whether email is verified
*/
export async function updateUserEmail(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string
emailAddress: string,
emailVerified = false
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// Search to see if this email has been used before.
let user = await collection(mongo).findOne({
tenantID,
email,
});
if (user) {
throw new DuplicateEmailError(email);
}
// The email wasn't found, so try to update the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
},
{
$set: {
email,
try {
// The email wasn't found, so try to update the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
{
$set: {
email,
emailVerified,
"profiles.$[profiles].id": email,
},
},
{
arrayFilters: [{ "profiles.type": "local" }],
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
throw new Error("an unexpected error occurred");
}
return result.value;
} catch (err) {
if (err instanceof MongoError && err.code === 11000) {
throw new DuplicateEmailError(email!);
}
throw err;
}
return result.value;
}
/**
+103 -4
View File
@@ -22,7 +22,10 @@ import {
UsernameUpdatedWithinWindowError,
UserNotFoundError,
} from "coral-server/errors";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import {
GQLAuthIntegrations,
GQLUSER_ROLE,
} from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import {
@@ -58,6 +61,8 @@ import {
} from "coral-server/models/user/helpers";
import { userIsStaff } from "coral-server/models/user/helpers";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { sendConfirmationEmail } from "coral-server/services/users/auth";
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
import { generateDownloadLink } from "./download/download";
@@ -388,6 +393,11 @@ export async function updateUsername(
// Validate the username.
validateUsername(username);
const canUpdate = canUpdateLocalProfile(tenant, user);
if (!canUpdate) {
throw new Error("Cannot update profile due to tenant settings");
}
// Get the earliest date that the username could have been edited before to/
// allow it now.
const lastUsernameEditAllowed = DateTime.fromJSDate(now)
@@ -483,7 +493,96 @@ export async function updateRole(
}
/**
* updateEmail will update the given User's email address. This should not
* enabledAuthenticationIntegrations returns enabled auth integrations for a tenant
* @param tenant Tenant where the User will be interacted with
* @param target whether to filter by stream or admin enabled. defaults to requiring both.
*/
function enabledAuthenticationIntegrations(
tenant: Tenant,
target?: "stream" | "admin"
): string[] {
return Object.keys(tenant.auth.integrations).filter((key: string) => {
const { enabled, targetFilter } = tenant.auth.integrations[
key as keyof GQLAuthIntegrations
];
if (target) {
return enabled && targetFilter[target];
}
return enabled && targetFilter.admin && targetFilter.stream;
});
}
/**
* canUpdateLocalProfile will determine if a user is permitted to update their email address.
* @param tenant Tenant where the User will be interacted with
* @param user the User that we are updating
*/
function canUpdateLocalProfile(tenant: Tenant, user: User): boolean {
if (!hasLocalProfile(user)) {
return false;
}
const streamAuthTypes = enabledAuthenticationIntegrations(tenant, "stream");
// user can update email if local auth is enabled or any integration other than sso is enabled
return (
streamAuthTypes.includes("local") ||
!(streamAuthTypes.length === 1 && streamAuthTypes[0] === "sso")
);
}
/**
* updateEmail will update the current User's email address.
* @param mongo mongo database to interact with
* @param tenant Tenant where the User will be interacted with
* @param mailer The mailer queue
* @param config Convict config
* @param user the User that we are updating
* @param email the email address that we are setting on the User
* @param password the users password for confirmation
*/
export async function updateEmail(
mongo: Db,
tenant: Tenant,
mailer: MailerQueue,
config: Config,
signingConfig: JWTSigningConfig,
user: User,
emailAddress: string,
password: string,
now = new Date()
) {
const email = emailAddress.toLowerCase();
validateEmail(email);
const canUpdate = canUpdateLocalProfile(tenant, user);
if (!canUpdate) {
throw new Error("Cannot update profile due to tenant settings");
}
const passwordVerified = await verifyUserPassword(user, password);
if (!passwordVerified) {
// We throw a PasswordIncorrect error here instead of an
// InvalidCredentialsError because the current user is already signed in.
throw new PasswordIncorrect();
}
const updated = await updateUserEmail(mongo, tenant.id, user.id, email);
await sendConfirmationEmail(
mongo,
mailer,
tenant,
config,
signingConfig,
updated as Required<User>,
now
);
return updated;
}
/**
* updateUserEmail will update the given User's email address. This should not
* trigger and email notifications as it's designed to be used by administrators
* to update a user's email address.
*
@@ -492,7 +591,7 @@ export async function updateRole(
* @param userID the User's ID that we are updating
* @param email the email address that we are setting on the User
*/
export async function updateEmail(
export async function updateEmailByID(
mongo: Db,
tenant: Tenant,
userID: string,
@@ -501,7 +600,7 @@ export async function updateEmail(
// Validate the email address.
validateEmail(email);
return updateUserEmail(mongo, tenant.id, userID, email);
return updateUserEmail(mongo, tenant.id, userID, email, true);
}
/**
+18
View File
@@ -278,3 +278,21 @@ suspendInfo-info =
account has been temporarily suspended. While suspended you will not
be able to comment, respect or report comments. Please rejoin the
conversation on { $until }
profile-changeEmail-unverified = (Unverified)
profile-changeEmail-edit = Edit
profile-changeEmail-please-verify = Verify your email address
profile-changeEmail-please-verify-details =
An email has been sent to { $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.
profile-changeEmail-resend = Resend verification
profile-changeEmail-heading = Edit your email address
profile-changeEmail-desc = Change the email address used for signing in and for receiving communication about your account.
profile-changeEmail-current = Current email
profile-changeEmail-newEmail-label = New email address
profile-changeEmail-password = Password
profile-changeEmail-password-input =
.placeholder = Password
profile-changeEmail-cancel = Cancel
profile-changeEmail-submit = Save