From ee93267d971a724d4cd2045f478b912b86b61e4a Mon Sep 17 00:00:00 2001 From: Tessa Thornton Date: Tue, 20 Aug 2019 12:28:14 -0400 Subject: [PATCH] [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 --- package-lock.json | 2 +- .../helpers/getAuthenticationIntegrations.ts | 33 ++ .../ChangeEmail/ChangeEmailContainer.css | 36 ++ .../ChangeEmail/ChangeEmailContainer.tsx | 391 ++++++++++++++++++ .../ChangeEmail/UpdateEmailMutation.ts | 46 +++ .../stream/tabs/Profile/ChangeEmail/index.ts | 4 + .../ChangeUsernameContainer.tsx | 75 +++- .../client/stream/tabs/Profile/Profile.tsx | 19 +- .../stream/tabs/Profile/ProfileContainer.tsx | 3 + src/core/client/stream/test/fixtures.ts | 15 + .../__snapshots__/settings.spec.tsx.snap | 72 +++- .../stream/test/profile/changeEmail.spec.tsx | 137 ++++++ .../server/graph/tenant/mutators/Users.ts | 15 +- .../server/graph/tenant/resolvers/Mutation.ts | 4 + .../server/graph/tenant/schema/schema.graphql | 38 ++ src/core/server/models/user/user.ts | 70 ++-- src/core/server/services/users/index.ts | 107 ++++- src/locales/en-US/stream.ftl | 18 + 18 files changed, 1016 insertions(+), 69 deletions(-) create mode 100644 src/core/client/framework/helpers/getAuthenticationIntegrations.ts create mode 100644 src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.css create mode 100644 src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.tsx create mode 100644 src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts create mode 100644 src/core/client/stream/tabs/Profile/ChangeEmail/index.ts create mode 100644 src/core/client/stream/test/profile/changeEmail.spec.tsx diff --git a/package-lock.json b/package-lock.json index 1dc609734..df82f1721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 } diff --git a/src/core/client/framework/helpers/getAuthenticationIntegrations.ts b/src/core/client/framework/helpers/getAuthenticationIntegrations.ts new file mode 100644 index 000000000..37c66518e --- /dev/null +++ b/src/core/client/framework/helpers/getAuthenticationIntegrations.ts @@ -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 []; +} diff --git a/src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.css b/src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.css new file mode 100644 index 000000000..f40e53059 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.css @@ -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; +} \ No newline at end of file diff --git a/src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.tsx b/src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.tsx new file mode 100644 index 000000000..d3f6965be --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeEmail/ChangeEmailContainer.tsx @@ -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("/account/confirm", { + method: "POST", + }); + } +); + +interface Props { + viewer: ViewerData; + settings: SettingsData; +} + +interface FormProps { + email: string; + password: string; +} + +const changeEmailContainer: FunctionComponent = ({ + 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 ( + + {!showEditForm && ( + + {viewer.email}{" "} + {!viewer.emailVerified && ( + + (Unverified) + + )} + {canChangeEmail && ( + + + + )} + + )} + {!viewer.emailVerified && emailUpdated && !showEditForm && ( + + +
+ email +
+
+ + + Verify your email address + + + + + 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. + + + + + + +
+
+
+ )} + {confirmationResent && ( + + + Your confirmation email has been re-sent. + + + )} + {showEditForm && ( + + +
+ + + Edit your email address + + + + + Change the email address used for signing in and for receiving + communication about your account. + + +
+
+ + + Current email + + + {viewer.email} +
+
+ {({ + handleSubmit, + submitError, + invalid, + submitting, + ...formProps + }) => ( + + + + + + + New email address + + + + {({ input, meta }) => ( + <> + + + + )} + + + + + + + {({ input, meta }) => ( + + + + Password + + + + + + + + )} + + + + {submitError && ( + + {submitError} + + )} + + + + + + + + + +
+ )} + +
+
+ )} +
+ ); +}; + +const enhanced = withFragmentContainer({ + 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; diff --git a/src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts b/src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts new file mode 100644 index 000000000..5cc5fe106 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeEmail/UpdateEmailMutation.ts @@ -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) => + commitMutationPromiseNormalized(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; diff --git a/src/core/client/stream/tabs/Profile/ChangeEmail/index.ts b/src/core/client/stream/tabs/Profile/ChangeEmail/index.ts new file mode 100644 index 000000000..43a4d6d5c --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeEmail/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as ChangeEmailContainer, +} from "./ChangeEmailContainer"; diff --git a/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.tsx b/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.tsx index cb6ee011c..982d465fd 100644 --- a/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.tsx +++ b/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.tsx @@ -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 = ({ viewer }) => { +const ChangeUsernameContainer: FunctionComponent = ({ + viewer, + settings, +}) => { const [showEditForm, setShowEditForm] = useState(false); const [showSuccessMessage, setShowSuccessMessage] = useState(false); const toggleEditForm = useCallback(() => { @@ -69,6 +75,20 @@ const ChangeUsernameContainer: FunctionComponent = ({ 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 = ({ viewer }) => { {!showEditForm && ( {viewer.username} - - - + {canChangeLocalAuth && ( + + + + )} )} {showEditForm && ( @@ -337,6 +359,9 @@ const enhanced = withFragmentContainer({ viewer: graphql` fragment ChangeUsernameContainer_viewer on User { username + profiles { + __typename + } status { username { history { @@ -347,6 +372,44 @@ const enhanced = withFragmentContainer({ } } `, + 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; diff --git a/src/core/client/stream/tabs/Profile/Profile.tsx b/src/core/client/stream/tabs/Profile/Profile.tsx index 6b4999e50..c7f43eb3f 100644 --- a/src/core/client/stream/tabs/Profile/Profile.tsx +++ b/src/core/client/stream/tabs/Profile/Profile.tsx @@ -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["viewer"] & PropTypesOf["viewer"] & PropTypesOf["viewer"] & - PropTypesOf["viewer"]; + PropTypesOf["viewer"] & + PropTypesOf["viewer"] & + PropTypesOf["viewer"]; settings: PropTypesOf["settings"] & - PropTypesOf["settings"]; + PropTypesOf["settings"] & + PropTypesOf["settings"] & + PropTypesOf["settings"]; } const Profile: FunctionComponent = props => { @@ -39,7 +44,13 @@ const Profile: FunctionComponent = props => { ); return ( - + + + + ({ ...CommentHistoryContainer_viewer ...SettingsContainer_viewer ...ChangeUsernameContainer_viewer + ...ChangeEmailContainer_viewer } `, settings: graphql` fragment ProfileContainer_settings on Settings { ...UserBoxContainer_settings + ...ChangeEmailContainer_settings + ...ChangeUsernameContainer_settings ...SettingsContainer_settings } `, diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index b2916ee41..6500c00e5 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -113,6 +113,8 @@ export const settingsWithoutLocalAuth = createFixture( export const baseUser = createFixture({ 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({ }, }, ignoreable: true, + profiles: [ + { + __typename: "LocalProfile", + }, + ], }); const yesterday = new Date(); @@ -184,6 +191,14 @@ export const userWithChangedUsername = createFixture( baseUser ); +export const userWithEmail = createFixture( + { + id: "email-user", + email: "used-email@email.com", + }, + baseUser +); + export const commenters = createFixtures( [ { diff --git a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap index 45680b104..1f49ce63c 100644 --- a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap +++ b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap @@ -66,29 +66,65 @@ exports[`renders the empty settings pane 1`] = ` className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5" >
-

- Passivo -

- +
+
+
+
- edit - +

+ +   + +

+ (Unverified) +

+ +
    = {} +) { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + 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({ + 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, + }); + }); +}); diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 1c9a5d56a..a7c300491 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -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) => diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index b01dba0b5..fe952618a 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -145,6 +145,10 @@ export const Mutation: Required> = { 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, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 6e097acb9..fab1826df 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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. diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index bdcff08df..6ba370ccf 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -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; } /** diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index c628431b6..3350ce96e 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -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, + 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); } /** diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 4f9f0ca83..46ff3f1fa 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -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