From 836a2267bf516fdc230f1eb5b733d73d48beb025 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 1 Aug 2019 22:08:14 +0000 Subject: [PATCH] [CORL-445] Change Password (#2426) * fix: reversed `new-password` autocomplete option * feat: initial implementation * fix: localization and testing * fix: updated snapshot --- .vscode/settings.json | 2 +- .../sections/Auth/ClientSecretField.tsx | 1 - .../routes/Configure/sections/Email/SMTP.tsx | 1 - .../sections/Moderation/APIKeyField.tsx | 1 - .../__snapshots__/auth.spec.tsx.snap | 14 +- .../__snapshots__/moderation.spec.tsx.snap | 4 +- src/core/client/auth/App/App.spec.tsx | 1 + src/core/client/auth/App/App.tsx | 9 +- src/core/client/auth/App/AppContainer.tsx | 11 +- .../views/ForgotPassword/ForgotPassword.tsx | 15 - .../ForgotPasswordContainer.tsx | 39 ++ .../ForgotPassword/ForgotPasswordForm.tsx | 37 +- .../client/auth/views/ForgotPassword/index.ts | 5 +- .../UserBox/SetAuthPopupStateMutation.ts | 7 +- .../stream/mutations/ShowAuthPopupMutation.ts | 7 +- .../client/stream/tabs/Profile/Profile.tsx | 5 +- .../stream/tabs/Profile/ProfileContainer.tsx | 1 + .../tabs/Profile/Settings/ChangePassword.tsx | 170 +++++++++ .../Settings/ChangePasswordContainer.tsx | 97 +++++ .../Settings/IgnoreUserSettingsContainer.css | 4 - .../Settings/IgnoreUserSettingsContainer.tsx | 5 +- .../Profile/Settings/SettingsContainer.css | 3 + .../Profile/Settings/SettingsContainer.tsx | 24 +- .../Settings/UpdatePasswordMutation.tsx | 33 ++ src/core/client/stream/test/fixtures.ts | 13 + .../__snapshots__/settings.spec.tsx.snap | 337 ++++++++++++++++++ .../stream/test/profile/ignoredUsers.spec.tsx | 92 ----- .../stream/test/profile/settings.spec.tsx | 186 ++++++++++ .../client/ui/components/CallOut/CallOut.css | 6 + .../client/ui/components/CallOut/CallOut.tsx | 3 +- src/core/common/errors.ts | 6 + .../app/handlers/api/auth/local/signup.ts | 2 + src/core/server/app/handlers/api/install.ts | 2 + .../passport/strategies/verifiers/sso.spec.ts | 8 +- src/core/server/errors/index.ts | 8 + src/core/server/errors/translations.ts | 1 + .../server/graph/tenant/loaders/Comments.ts | 7 +- .../server/graph/tenant/mutators/Users.ts | 16 +- .../server/graph/tenant/resolvers/Mutation.ts | 2 + .../server/graph/tenant/schema/schema.graphql | 10 +- src/core/server/locales/en-US/errors.ftl | 1 + src/core/server/models/user/helpers.spec.ts | 11 +- src/core/server/models/user/helpers.ts | 21 +- src/core/server/models/user/user.ts | 40 ++- src/core/server/services/users/auth/invite.ts | 1 + src/core/server/services/users/auth/reset.ts | 16 +- src/core/server/services/users/index.ts | 27 +- src/locales/en-US/stream.ftl | 7 + 48 files changed, 1122 insertions(+), 197 deletions(-) delete mode 100644 src/core/client/auth/views/ForgotPassword/ForgotPassword.tsx create mode 100644 src/core/client/auth/views/ForgotPassword/ForgotPasswordContainer.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/ChangePasswordContainer.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/SettingsContainer.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx create mode 100644 src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap delete mode 100644 src/core/client/stream/test/profile/ignoredUsers.spec.tsx create mode 100644 src/core/client/stream/test/profile/settings.spec.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 433ed2315..122abc2a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,6 @@ "tslint.autoFixOnSave": true, "tslint.jsEnable": true, "tslint.nodePath": "node_modules/.bin/tslint", - "typescript.tsdk": "node_modules/typescript/lib", + "typescript.tsdk": "./node_modules/typescript/lib", "postcss.validate": false } diff --git a/src/core/client/admin/routes/Configure/sections/Auth/ClientSecretField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/ClientSecretField.tsx index 78f1784f9..8a0ea1a8f 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/ClientSecretField.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/ClientSecretField.tsx @@ -32,7 +32,6 @@ const ClientSecretField: FunctionComponent = ({ <> = ({ disabled }) => ( <> = ({ { const props: PropTypesOf = { view: "SIGN_IN", auth: {}, + viewer: null, }; const renderer = createRenderer(); renderer.render(); diff --git a/src/core/client/auth/App/App.tsx b/src/core/client/auth/App/App.tsx index 0aed14d72..33badaed1 100644 --- a/src/core/client/auth/App/App.tsx +++ b/src/core/client/auth/App/App.tsx @@ -23,18 +23,19 @@ export type View = export interface AppProps { view: View; + viewer: PropTypesOf["viewer"]; auth: PropTypesOf["auth"] & PropTypesOf["auth"]; } -const renderView = (view: AppProps["view"], auth: AppProps["auth"]) => { +const render = ({ view, auth, viewer }: AppProps) => { switch (view) { case "SIGN_UP": return ; case "SIGN_IN": return ; case "FORGOT_PASSWORD": - return ; + return ; case "CREATE_USERNAME": return ; case "CREATE_PASSWORD": @@ -46,10 +47,10 @@ const renderView = (view: AppProps["view"], auth: AppProps["auth"]) => { } }; -const App: FunctionComponent = ({ view, auth }) => ( +const App: FunctionComponent = props => ( <> {process.env.NODE_ENV !== "test" && } -
{renderView(view, auth)}
+
{render(props)}
); diff --git a/src/core/client/auth/App/AppContainer.tsx b/src/core/client/auth/App/AppContainer.tsx index 80aef623b..9588561b8 100644 --- a/src/core/client/auth/App/AppContainer.tsx +++ b/src/core/client/auth/App/AppContainer.tsx @@ -25,9 +25,17 @@ class AppContainer extends Component { auth, viewer, } = this.props; + + // If we're dealing with a password reset, we can't possibly worry about + // account completion (because they are not logged in, or have already + // completed their account), so disregard here, and just return the App. + if (view === "FORGOT_PASSWORD") { + return ; + } + return ( - + ); } @@ -51,6 +59,7 @@ const enhanced = withLocalStateContainer( viewer: graphql` fragment AppContainer_viewer on User { ...AccountCompletionContainer_viewer + ...ForgotPasswordContainer_viewer } `, })(AppContainer) diff --git a/src/core/client/auth/views/ForgotPassword/ForgotPassword.tsx b/src/core/client/auth/views/ForgotPassword/ForgotPassword.tsx deleted file mode 100644 index e6a12ed62..000000000 --- a/src/core/client/auth/views/ForgotPassword/ForgotPassword.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React, { FunctionComponent, useState } from "react"; - -import CheckEmail from "./CheckEmail"; -import ForgotPasswordForm from "./ForgotPasswordForm"; - -const ForgotPassword: FunctionComponent = () => { - const [checkEmail, setCheckEmail] = useState(null); - return checkEmail ? ( - - ) : ( - - ); -}; - -export default ForgotPassword; diff --git a/src/core/client/auth/views/ForgotPassword/ForgotPasswordContainer.tsx b/src/core/client/auth/views/ForgotPassword/ForgotPasswordContainer.tsx new file mode 100644 index 000000000..48245bd95 --- /dev/null +++ b/src/core/client/auth/views/ForgotPassword/ForgotPasswordContainer.tsx @@ -0,0 +1,39 @@ +import React, { FunctionComponent, useState } from "react"; + +import { ForgotPasswordContainer_viewer } from "coral-auth/__generated__/ForgotPasswordContainer_viewer.graphql"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; + +import CheckEmail from "./CheckEmail"; +import ForgotPasswordForm from "./ForgotPasswordForm"; + +interface Props { + viewer: ForgotPasswordContainer_viewer | null; +} + +const ForgotPasswordContainer: FunctionComponent = ({ viewer }) => { + const [checkEmail, setCheckEmail] = useState(null); + + // We rely on the email address being provided when the user is logged in. + // Normally we'd have to be concerned about how the auth token is being passed + // because of the auth state issues present with browsers that do weird things + // to segment data, but thankfully this is only used for local auth, which + // means that the auth data in the browser is the same as what's available to + // the embed. + const email = viewer ? viewer.email : null; + + return checkEmail ? ( + + ) : ( + + ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment ForgotPasswordContainer_viewer on User { + email + } + `, +})(ForgotPasswordContainer); + +export default enhanced; diff --git a/src/core/client/auth/views/ForgotPassword/ForgotPasswordForm.tsx b/src/core/client/auth/views/ForgotPassword/ForgotPasswordForm.tsx index bb6c1be6f..a8ce0b206 100644 --- a/src/core/client/auth/views/ForgotPassword/ForgotPasswordForm.tsx +++ b/src/core/client/auth/views/ForgotPassword/ForgotPasswordForm.tsx @@ -35,20 +35,22 @@ interface FormProps { } interface Props { + email: string | null; onCheckEmail: (email: string) => void; } -const ForgotPasswordForm: FunctionComponent = ({ onCheckEmail }) => { +const ForgotPasswordForm: FunctionComponent = ({ + email, + onCheckEmail, +}) => { const signInHref = getViewURL("SIGN_IN"); const forgotPassword = useMutation(ForgotPasswordMutation); const setView = useMutation(SetViewMutation); const onSubmit = useCallback( - async ({ email }: FormProps) => { + async (form: FormProps) => { try { - await forgotPassword({ - email, - }); - onCheckEmail(email); + await forgotPassword(form); + onCheckEmail(form.email); } catch (error) { if (error instanceof InvalidRequestError) { return error.invalidArgs; @@ -76,17 +78,20 @@ const ForgotPasswordForm: FunctionComponent = ({ onCheckEmail }) => { Forgot Password? - - - - - Go back to sign in page - - - - + {/* If an email address has been provided, then they are already logged in. */} + {!email && ( + + + + + Go back to sign in page + + + + + )}
-
+ {({ handleSubmit, submitting, submitError }) => ( diff --git a/src/core/client/auth/views/ForgotPassword/index.ts b/src/core/client/auth/views/ForgotPassword/index.ts index 7e5a4f7f7..4170b5018 100644 --- a/src/core/client/auth/views/ForgotPassword/index.ts +++ b/src/core/client/auth/views/ForgotPassword/index.ts @@ -1 +1,4 @@ -export { default, default as ForgotPassword } from "./ForgotPassword"; +export { + default, + default as ForgotPasswordContainer, +} from "./ForgotPasswordContainer"; diff --git a/src/core/client/stream/common/UserBox/SetAuthPopupStateMutation.ts b/src/core/client/stream/common/UserBox/SetAuthPopupStateMutation.ts index 6afee9270..3f398ad09 100644 --- a/src/core/client/stream/common/UserBox/SetAuthPopupStateMutation.ts +++ b/src/core/client/stream/common/UserBox/SetAuthPopupStateMutation.ts @@ -1,6 +1,9 @@ import { commitLocalUpdate, Environment } from "relay-runtime"; -import { createMutationContainer } from "coral-framework/lib/relay"; +import { + createMutation, + createMutationContainer, +} from "coral-framework/lib/relay"; import { AUTH_POPUP_ID } from "coral-stream/local"; @@ -36,3 +39,5 @@ export const withSetAuthPopupStateMutation = createMutationContainer( "setAuthPopupState", commit ); + +export default createMutation("setAuthPopupState", commit); diff --git a/src/core/client/stream/mutations/ShowAuthPopupMutation.ts b/src/core/client/stream/mutations/ShowAuthPopupMutation.ts index 5261d500b..36e0b2515 100644 --- a/src/core/client/stream/mutations/ShowAuthPopupMutation.ts +++ b/src/core/client/stream/mutations/ShowAuthPopupMutation.ts @@ -1,6 +1,9 @@ import { commitLocalUpdate, Environment } from "relay-runtime"; -import { createMutationContainer } from "coral-framework/lib/relay"; +import { + createMutation, + createMutationContainer, +} from "coral-framework/lib/relay"; import { AUTH_POPUP_ID } from "../local"; @@ -34,3 +37,5 @@ export const withShowAuthPopupMutation = createMutationContainer( "showAuthPopup", commit ); + +export default createMutation("showAuthPopup", commit); diff --git a/src/core/client/stream/tabs/Profile/Profile.tsx b/src/core/client/stream/tabs/Profile/Profile.tsx index b4b088b79..8d041a302 100644 --- a/src/core/client/stream/tabs/Profile/Profile.tsx +++ b/src/core/client/stream/tabs/Profile/Profile.tsx @@ -21,7 +21,8 @@ export interface ProfileProps { viewer: PropTypesOf["viewer"] & PropTypesOf["viewer"] & PropTypesOf["viewer"]; - settings: PropTypesOf["settings"]; + settings: PropTypesOf["settings"] & + PropTypesOf["settings"]; } const Profile: FunctionComponent = props => { @@ -58,7 +59,7 @@ const Profile: FunctionComponent = props => { - + diff --git a/src/core/client/stream/tabs/Profile/ProfileContainer.tsx b/src/core/client/stream/tabs/Profile/ProfileContainer.tsx index bca2b892d..2a614d019 100644 --- a/src/core/client/stream/tabs/Profile/ProfileContainer.tsx +++ b/src/core/client/stream/tabs/Profile/ProfileContainer.tsx @@ -41,6 +41,7 @@ const enhanced = withFragmentContainer({ settings: graphql` fragment ProfileContainer_settings on Settings { ...UserBoxContainer_settings + ...SettingsContainer_settings } `, })(ProfileContainer); diff --git a/src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx b/src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx new file mode 100644 index 000000000..31d45ce95 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/ChangePassword.tsx @@ -0,0 +1,170 @@ +import { FORM_ERROR, FormApi } from "final-form"; +import React, { FunctionComponent, useCallback } from "react"; +import { Field, Form } from "react-final-form"; + +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form"; +import { useMutation } from "coral-framework/lib/relay"; +import { + composeValidators, + required, + validatePassword, +} from "coral-framework/lib/validation"; +import { + Button, + CallOut, + FieldSet, + Flex, + FormField, + HorizontalGutter, + InputLabel, + PasswordField, + Typography, +} from "coral-ui/components"; + +import { Localized } from "fluent-react/compat"; +import UpdatePasswordMutation from "./UpdatePasswordMutation"; + +interface Props { + onResetPassword: () => void; +} + +interface FormProps { + oldPassword: string; + newPassword: string; +} + +const ChangePassword: FunctionComponent = ({ onResetPassword }) => { + const updatePassword = useMutation(UpdatePasswordMutation); + const onSubmit = useCallback( + async (input: FormProps, form: FormApi) => { + try { + await updatePassword(input); + } catch (err) { + if (err instanceof InvalidRequestError) { + return err.invalidArgs; + } + + return { + [FORM_ERROR]: err.message, + }; + } + + // Reset the form now that we're done. + form.reset(); + + return; + }, + [updatePassword] + ); + + return ( +
+ + {({ + handleSubmit, + submitting, + submitError, + pristine, + submitSucceeded, + }) => ( + + + + Change Password + + }> + + {({ input, meta }) => ( + }> + + + Old Password + + + + + + + + + + + + + + )} + + + {({ input, meta }) => ( + }> + + + New Password + + + + + + )} + + {submitError && ( + + {submitError} + + )} + {submitSucceeded && ( + + + Your password has been updated + + + )} + + + + + + + + + )} + +
+ ); +}; + +export default ChangePassword; diff --git a/src/core/client/stream/tabs/Profile/Settings/ChangePasswordContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/ChangePasswordContainer.tsx new file mode 100644 index 000000000..c9bd6f93b --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/ChangePasswordContainer.tsx @@ -0,0 +1,97 @@ +import React, { FunctionComponent, useCallback } from "react"; +import { graphql } from "react-relay"; + +import { urls } from "coral-framework/helpers"; +import { + useMutation, + withFragmentContainer, + withLocalStateContainer, +} from "coral-framework/lib/relay"; +import { ChangePasswordContainer_settings } from "coral-stream/__generated__/ChangePasswordContainer_settings.graphql"; +import { ChangePasswordContainerLocal } from "coral-stream/__generated__/ChangePasswordContainerLocal.graphql"; +import SetAuthPopupStateMutation from "coral-stream/common/UserBox/SetAuthPopupStateMutation"; +import ShowAuthPopupMutation from "coral-stream/mutations/ShowAuthPopupMutation"; +import { Popup } from "coral-ui/components"; + +import ChangePassword from "./ChangePassword"; + +interface Props { + local: ChangePasswordContainerLocal; + settings: ChangePasswordContainer_settings; +} + +const ChangePasswordContainer: FunctionComponent = ({ + settings, + local: { + authPopup: { open, focus, view }, + }, +}) => { + const setAuthPopupState = useMutation(SetAuthPopupStateMutation); + const showAuthPopup = useMutation(ShowAuthPopupMutation); + const onResetPassword = useCallback(() => { + showAuthPopup({ view: "FORGOT_PASSWORD" }); + }, [showAuthPopup]); + const onFocus = useCallback(() => { + setAuthPopupState({ focus: true }); + }, [setAuthPopupState]); + const onBlur = useCallback(() => { + setAuthPopupState({ focus: true }); + }, [setAuthPopupState]); + const onClose = useCallback(() => { + setAuthPopupState({ open: false }); + }, [setAuthPopupState]); + + if ( + !settings.auth.integrations.local.enabled || + !settings.auth.integrations.local.targetFilter.stream + ) { + return null; + } + + return ( + <> + + + + ); +}; + +const enhanced = withLocalStateContainer( + graphql` + fragment ChangePasswordContainerLocal on Local { + authPopup { + open + focus + view + } + } + ` +)( + withFragmentContainer({ + settings: graphql` + fragment ChangePasswordContainer_settings on Settings { + auth { + integrations { + local { + enabled + targetFilter { + stream + } + } + } + } + } + `, + })(ChangePasswordContainer) +); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.css b/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.css index 277a793ab..23d2d562c 100644 --- a/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.css +++ b/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.css @@ -1,7 +1,3 @@ -.root { - max-width: 440px; -} - .description { color: var(--palette-text-primary); font-family: var(--font-family-sans-serif); diff --git a/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx index f6ac5014b..3d6434d67 100644 --- a/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/IgnoreUserSettingsContainer.tsx @@ -23,10 +23,7 @@ interface Props { const IgnoreUserSettingsContainer: FunctionComponent = ({ viewer }) => { const removeUserIgnore = useMutation(RemoveUserIgnoreMutation); return ( -
+
Ignored Commenters diff --git a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.css b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.css new file mode 100644 index 000000000..07a86050e --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.css @@ -0,0 +1,3 @@ +.root { + max-width: 440px; +} diff --git a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx index 02e724ff9..8406ef682 100644 --- a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx @@ -2,17 +2,26 @@ import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; import { withFragmentContainer } from "coral-framework/lib/relay"; -import { SettingsContainer_viewer as ViewerData } from "coral-stream/__generated__/SettingsContainer_viewer.graphql"; +import { SettingsContainer_settings } from "coral-stream/__generated__/SettingsContainer_settings.graphql"; +import { SettingsContainer_viewer } from "coral-stream/__generated__/SettingsContainer_viewer.graphql"; +import { HorizontalGutter } from "coral-ui/components"; +import ChangePasswordContainer from "./ChangePasswordContainer"; import IgnoreUserSettingsContainer from "./IgnoreUserSettingsContainer"; +import styles from "./SettingsContainer.css"; + interface Props { - viewer: ViewerData; + viewer: SettingsContainer_viewer; + settings: SettingsContainer_settings; } -const SettingsContainer: FunctionComponent = ({ viewer }) => { - return ; -}; +const SettingsContainer: FunctionComponent = ({ viewer, settings }) => ( + + + + +); const enhanced = withFragmentContainer({ viewer: graphql` @@ -20,6 +29,11 @@ const enhanced = withFragmentContainer({ ...IgnoreUserSettingsContainer_viewer } `, + settings: graphql` + fragment SettingsContainer_settings on Settings { + ...ChangePasswordContainer_settings + } + `, })(SettingsContainer); export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx b/src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx new file mode 100644 index 000000000..ba25e3ec1 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/UpdatePasswordMutation.tsx @@ -0,0 +1,33 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; +import { UpdatePasswordMutation as MutationTypes } from "coral-stream/__generated__/UpdatePasswordMutation.graphql"; + +let clientMutationId = 0; + +const UpdatePasswordMutation = createMutation( + "updatePassword", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation UpdatePasswordMutation($input: UpdatePasswordInput!) { + updatePassword(input: $input) { + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default UpdatePasswordMutation; diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 669c4d48d..7a7d4f9e5 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -93,6 +93,19 @@ export const settings = createFixture({ }, }); +export const settingsWithoutLocalAuth = createFixture( + { + auth: { + integrations: { + local: { + enabled: false, + }, + }, + }, + }, + settings +); + export const baseUser = createFixture({ createdAt: "2018-02-06T18:24:00.000Z", status: { 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 new file mode 100644 index 000000000..a0fb6b6b7 --- /dev/null +++ b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap @@ -0,0 +1,337 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders the empty settings pane 1`] = ` +
+
    + + +
+
+
+
+
+
+ Signed in as + + Passivo + + . +
+
+ + Not you?  + + +
+
+
+
    + + +
+
+
+
+

+ Ignored Commenters +

+

+ Once you ignore someone, all of their comments are hidden from you. +Commenters you ignore will still be able to see your comments. +

+
+
+ You are not currently ignoring anyone +
+
+
+
+
+
+

+ Change Password +

+
+
+ +
+
+ +
+ +
+
+
+
+

+ Forgot your password? +

+
+
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/core/client/stream/test/profile/ignoredUsers.spec.tsx b/src/core/client/stream/test/profile/ignoredUsers.spec.tsx deleted file mode 100644 index f3f5ad836..000000000 --- a/src/core/client/stream/test/profile/ignoredUsers.spec.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { pureMerge } from "coral-common/utils"; -import { GQLResolver } from "coral-framework/schema"; -import { - createResolversStub, - CreateTestRendererParams, - waitForElement, - waitUntilThrow, - within, -} from "coral-framework/testHelpers"; - -import { commenters, settings, stories, viewerPassive } from "../fixtures"; -import create from "./create"; - -const story = stories[0]; -const viewer = viewerPassive; - -async function createTestRenderer( - params: CreateTestRendererParams = {} -) { - const { testRenderer, context } = create({ - ...params, - resolvers: pureMerge( - createResolversStub({ - Query: { - settings: () => settings, - viewer: () => viewer, - story: () => story, - }, - }), - params.resolvers - ), - initLocalState: (localRecord, source, environment) => { - localRecord.setValue("SETTINGS", "profileTab"); - if (params.initLocalState) { - params.initLocalState(localRecord, source, environment); - } - }, - }); - - const section = await waitForElement(() => - within(testRenderer.root).getByTestID("profile-settings-ignoredCommenters") - ); - - return { - testRenderer, - context, - section, - }; -} - -it("render empty ignored users list", async () => { - const { section } = await createTestRenderer(); - await waitForElement(() => - within(section).getByText("You are not currently ignoring anyone", { - exact: false, - }) - ); -}); - -it("render ignored users list", async () => { - const { section } = await createTestRenderer({ - resolvers: createResolversStub({ - Query: { - viewer: () => - pureMerge(viewer, { - ignoredUsers: [commenters[0], commenters[1]], - }), - }, - Mutation: { - removeUserIgnore: ({ variables }) => { - expectAndFail(variables).toMatchObject({ - userID: commenters[0].id, - }); - return {}; - }, - }, - }), - }); - within(section).getByText(commenters[0].username!); - within(section).getByText(commenters[1].username!); - - // Stop ignoring first users. - within(section) - .getAllByText("Stop ignoring", { selector: "button" })[0] - .props.onClick(); - - // First user should dissappear from list. - await waitUntilThrow(() => - within(section).getByText(commenters[0].username!) - ); - within(section).getByText(commenters[1].username!); -}); diff --git a/src/core/client/stream/test/profile/settings.spec.tsx b/src/core/client/stream/test/profile/settings.spec.tsx new file mode 100644 index 000000000..78275a641 --- /dev/null +++ b/src/core/client/stream/test/profile/settings.spec.tsx @@ -0,0 +1,186 @@ +import sinon from "sinon"; + +import { pureMerge } from "coral-common/utils"; +import { GQLResolver } from "coral-framework/schema"; +import { + act, + createResolversStub, + CreateTestRendererParams, + waitForElement, + waitUntilThrow, + within, +} from "coral-framework/testHelpers"; + +import { + commenters, + settings, + settingsWithoutLocalAuth, + stories, + viewerPassive, +} from "../fixtures"; +import create from "./create"; + +const story = stories[0]; +const viewer = viewerPassive; + +async function createTestRenderer( + params: CreateTestRendererParams = {} +) { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + story: () => story, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue("SETTINGS", "profileTab"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + const ignoredCommenters = await waitForElement(() => + within(testRenderer.root).queryByTestID( + "profile-settings-ignoredCommenters" + ) + ); + + const changePassword = within(testRenderer.root).queryByTestID( + "profile-settings-changePassword" + ); + + return { + testRenderer, + context, + ignoredCommenters, + changePassword, + }; +} + +it("renders the empty settings pane", async () => { + const { + testRenderer: { root }, + } = await createTestRenderer(); + expect(within(root).toJSON()).toMatchSnapshot(); +}); + +it("doesn't show the change password pane when local auth is disabled", async () => { + const { changePassword } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + settings: () => settingsWithoutLocalAuth, + }, + }), + }); + expect(changePassword).toBeNull(); +}); + +it("render password change form", async () => { + const updatePassword = sinon.stub().callsFake((_: any, { input }) => { + expectAndFail(input).toMatchObject({ + oldPassword: "testtest", + newPassword: "testtest", + }); + return { + clientMutationId: input.clientMutationId, + }; + }); + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Mutation: { + updatePassword, + }, + }), + }); + const changePassword = await waitForElement(() => + within(testRenderer.root).getByTestID("profile-settings-changePassword") + ); + const form = within(changePassword).getByType("form"); + const oldPassword = await waitForElement(() => + within(form).getByID("oldPassword", { exact: false }) + ); + const newPassword = await waitForElement(() => + within(form).getByID("newPassword", { exact: false }) + ); + + // Submit an empty form. + act(() => { + form.props.onSubmit(); + }); + within(changePassword).getAllByText("field is required", { + exact: false, + }); + + // Password too short. + act(() => { + oldPassword.props.onChange("test"); + newPassword.props.onChange("test"); + }); + within(changePassword).getAllByText( + "Password must contain at least 8 characters", + { + exact: false, + } + ); + + await act(async () => { + oldPassword.props.onChange("testtest"); + newPassword.props.onChange("testtest"); + await form.props.onSubmit(); + }); + + expect(updatePassword.calledOnce).toBeTruthy(); +}); + +it("render empty ignored users list", async () => { + const { ignoredCommenters } = await createTestRenderer(); + await waitForElement(() => + within(ignoredCommenters).getByText( + "You are not currently ignoring anyone", + { + exact: false, + } + ) + ); +}); + +it("render ignored users list", async () => { + const { ignoredCommenters } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + viewer: () => + pureMerge(viewer, { + ignoredUsers: [commenters[0], commenters[1]], + }), + }, + Mutation: { + removeUserIgnore: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: commenters[0].id, + }); + return {}; + }, + }, + }), + }); + within(ignoredCommenters).getByText(commenters[0].username!); + within(ignoredCommenters).getByText(commenters[1].username!); + + // Stop ignoring first users. + within(ignoredCommenters) + .getAllByText("Stop ignoring", { selector: "button" })[0] + .props.onClick(); + + // First user should dissappear from list. + await waitUntilThrow(() => + within(ignoredCommenters).getByText(commenters[0].username!) + ); + within(ignoredCommenters).getByText(commenters[1].username!); +}); diff --git a/src/core/client/ui/components/CallOut/CallOut.css b/src/core/client/ui/components/CallOut/CallOut.css index da9130e71..4928968d6 100644 --- a/src/core/client/ui/components/CallOut/CallOut.css +++ b/src/core/client/ui/components/CallOut/CallOut.css @@ -29,6 +29,12 @@ color: var(--palette-text-primary); } +.colorSuccess { + background-color: var(--palette-success-lightest); + border-color: var(--palette-success-light); + color: var(--palette-text-primary); +} + .fullWidth { display: flex; width: 100%; diff --git a/src/core/client/ui/components/CallOut/CallOut.tsx b/src/core/client/ui/components/CallOut/CallOut.tsx index 05501e169..75a193777 100644 --- a/src/core/client/ui/components/CallOut/CallOut.tsx +++ b/src/core/client/ui/components/CallOut/CallOut.tsx @@ -22,7 +22,7 @@ export interface CallOutProps { /** * Color of the CallOut */ - color?: "regular" | "primary" | "error"; + color?: "regular" | "primary" | "error" | "success"; /* * If set renders a full width CallOut */ @@ -38,6 +38,7 @@ const CallOut: FunctionComponent = props => { [classes.colorRegular]: color === "regular", [classes.colorError]: color === "error", [classes.colorPrimary]: color === "primary", + [classes.colorSuccess]: color === "success", [classes.fullWidth]: fullWidth, }, className diff --git a/src/core/common/errors.ts b/src/core/common/errors.ts index c413993a0..b6523424c 100644 --- a/src/core/common/errors.ts +++ b/src/core/common/errors.ts @@ -136,6 +136,12 @@ export enum ERROR_CODES { */ PASSWORD_TOO_SHORT = "PASSWORD_TOO_SHORT", + /** + * PASSWORD_INCORRECT is returned when a logged in operation that requires the + * password returns the wrong password. + */ + PASSWORD_INCORRECT = "PASSWORD_INCORRECT", + /** * EMAIL_INVALID_FORMAT is returned when when the user attempts to associate a * new email address that is not a valid email address. diff --git a/src/core/server/app/handlers/api/auth/local/signup.ts b/src/core/server/app/handlers/api/auth/local/signup.ts index dd57358a1..a09ac635d 100644 --- a/src/core/server/app/handlers/api/auth/local/signup.ts +++ b/src/core/server/app/handlers/api/auth/local/signup.ts @@ -1,4 +1,5 @@ import Joi from "joi"; +import uuid from "uuid/v4"; import { AppOptions } from "coral-server/app"; import { handleSuccessfulLogin } from "coral-server/app/middleware/passport"; @@ -77,6 +78,7 @@ export const signupHandler = ({ id: email, type: "local", password, + passwordID: uuid(), }; // Create the new user. diff --git a/src/core/server/app/handlers/api/install.ts b/src/core/server/app/handlers/api/install.ts index 627ac8134..59e331f46 100644 --- a/src/core/server/app/handlers/api/install.ts +++ b/src/core/server/app/handlers/api/install.ts @@ -1,4 +1,5 @@ import Joi from "joi"; +import uuid from "uuid/v4"; import { LanguageCode, LOCALES } from "coral-common/helpers/i18n/locales"; import { Omit } from "coral-common/types"; @@ -113,6 +114,7 @@ export const installHandler = ({ type: "local", id: email, password, + passwordID: uuid(), }; // Create the first admin user. diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts index 84125245d..79910eedb 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts @@ -11,12 +11,14 @@ describe("isSSOToken", () => { }); it("understands invalid sso tokens", () => { - expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy(); expect( - isSSOToken({ user: { id: "id", username: "username" } }) + isSSOToken({ user: { id: "id", email: "email" } } as object) ).toBeFalsy(); expect( - isSSOToken({ user: { email: "email", username: "username" } }) + isSSOToken({ user: { id: "id", username: "username" } } as object) + ).toBeFalsy(); + expect( + isSSOToken({ user: { email: "email", username: "username" } } as object) ).toBeFalsy(); expect(isSSOToken({})).toBeFalsy(); }); diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index 5f94cb448..0e626d7e6 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -651,3 +651,11 @@ export class LiveUpdatesDisabled extends CoralError { }); } } + +export class PasswordIncorrect extends CoralError { + constructor() { + super({ + code: ERROR_CODES.PASSWORD_INCORRECT, + }); + } +} diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index 9ebf407c9..5b4318371 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -49,4 +49,5 @@ export const ERROR_TRANSLATIONS: Record = { INVITE_TOKEN_EXPIRED: "error-inviteTokenExpired", INVITE_REQUIRES_EMAIL_ADDRESSES: "error-inviteRequiresEmailAddresses", LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled", + PASSWORD_INCORRECT: "error-passwordIncorrect", }; diff --git a/src/core/server/graph/tenant/loaders/Comments.ts b/src/core/server/graph/tenant/loaders/Comments.ts index ebf3597e0..9612cecfa 100644 --- a/src/core/server/graph/tenant/loaders/Comments.ts +++ b/src/core/server/graph/tenant/loaders/Comments.ts @@ -74,8 +74,9 @@ const primeCommentsFromConnection = (ctx: Context) => ( */ const mapVisibleComment = (user?: Pick) => { // Check to see if this user is privileged and can view non-visible comments. - const isPrivilegedUser = - user && [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR].includes(user.role); + const isPrivilegedUser = Boolean( + user && [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR].includes(user.role) + ); // Return a function that will map out the non-visible comments if needed. return (comment: Readonly | null) => { @@ -103,7 +104,7 @@ const mapVisibleComments = (user?: Pick) => ( ): Array | null> => comments.map(mapVisibleComment(user)); export default (ctx: Context) => ({ - comment: new DataLoader( + comment: new DataLoader | null>( (ids: string[]) => retrieveManyComments(ctx.mongo, ctx.tenant.id, ids).then( mapVisibleComments(ctx.user) diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 715e0081b..009477fae 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -95,12 +95,16 @@ export const Users = (ctx: TenantContext) => ({ updatePassword: async ( input: GQLUpdatePasswordInput ): Promise | null> => - updatePassword( - ctx.mongo, - ctx.mailerQueue, - ctx.tenant, - ctx.user!, - input.password + mapFieldsetToErrorCodes( + updatePassword( + ctx.mongo, + ctx.mailerQueue, + ctx.tenant, + ctx.user!, + input.oldPassword, + input.newPassword + ), + { "input.oldPassword": [ERROR_CODES.PASSWORD_INCORRECT] } ), createToken: async (input: GQLCreateTokenInput) => createToken( diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 82cad12a0..0591ea9fc 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -1,5 +1,7 @@ import { GQLMutationTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +// TODO: (wyattjoh) add rate limiting to these edges + export const Mutation: Required> = { editComment: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comments.edit(input), diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 73b43a372..c54c83375 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -3979,9 +3979,15 @@ type SetPasswordPayload { input UpdatePasswordInput { """ - password is the new password that should be associated with the User. + oldPassword is the old password that that should be compared against when + trying to change the password before the change. """ - password: String! + oldPassword: String! + + """ + newPassword is the new password that should be associated with the User. + """ + newPassword: String! """ clientMutationId is required for Relay support. diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl index b02ca36f0..94ab279a8 100644 --- a/src/core/server/locales/en-US/errors.ftl +++ b/src/core/server/locales/en-US/errors.ftl @@ -53,3 +53,4 @@ error-emailConfirmTokenExpired = Email confirmation link expired. error-rateLimitExceeded = Rate limit exceeded. error-inviteTokenExpired = Invite link has expired. error-inviteRequiresEmailAddresses = Please add an email address to send invitations. +error-passwordIncorrect = Password provided was incorrect. diff --git a/src/core/server/models/user/helpers.spec.ts b/src/core/server/models/user/helpers.spec.ts index bc3e28f25..3679e9cf9 100644 --- a/src/core/server/models/user/helpers.spec.ts +++ b/src/core/server/models/user/helpers.spec.ts @@ -1,10 +1,13 @@ -import { LocalProfile, SSOProfile } from "coral-server/models/user"; +import uuid from "uuid/v1"; + import { getLocalProfile, hasLocalProfile } from "./helpers"; +import { LocalProfile, SSOProfile } from "./user"; const localProfile: LocalProfile = { type: "local", id: "hans@email.com", password: "secret", + passwordID: uuid(), }; const ssoProfile: SSOProfile = { @@ -25,6 +28,12 @@ it("will return true when the profile exists", () => { expect(hasLocalProfile({ profiles: [localProfile] })).toBeTruthy(); }); +it("will get the local profile with the correct email", () => { + expect( + getLocalProfile({ profiles: [localProfile] }, localProfile.id) + ).toEqual(localProfile); +}); + it("will return true when the profile exists with the right email", () => { expect( hasLocalProfile({ profiles: [localProfile] }, localProfile.id) diff --git a/src/core/server/models/user/helpers.ts b/src/core/server/models/user/helpers.ts index d87597a36..17a8ba712 100644 --- a/src/core/server/models/user/helpers.ts +++ b/src/core/server/models/user/helpers.ts @@ -34,11 +34,22 @@ export function needsSSOUpdate( * @param user the User to pull the LocalProfile out of */ export function getLocalProfile( - user: Pick + user: Pick, + withEmail?: string ): LocalProfile | undefined { - return user.profiles.find(({ type }) => type === "local") as + const profile = user.profiles.find(({ type }) => type === "local") as | LocalProfile | undefined; + + if (!profile) { + return; + } + + if (withEmail && profile.id !== withEmail) { + return; + } + + return profile; } /** @@ -53,14 +64,10 @@ export function hasLocalProfile( user: Pick, withEmail?: string ): boolean { - const profile = getLocalProfile(user); + const profile = getLocalProfile(user, withEmail); if (!profile) { return false; } - if (withEmail && profile.id !== withEmail) { - return false; - } - return true; } diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 96ef5319c..8a06ff5fb 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -47,6 +47,13 @@ export interface LocalProfile { id: string; password: string; + /** + * passwordID is used to help protect against double password change race + * conditions. Because the password cannot be compared against directly, this + * ID can be used as it is only changed when the password is changed. + */ + passwordID: string; + /** * resetID is used during a password reset process to prevent replay attacks. * When a password reset email is sent, a resetID is associated with the @@ -530,9 +537,10 @@ export async function updateUserRole( export async function verifyUserPassword( user: Pick, - password: string + password: string, + withEmail?: string ) { - const profile = getLocalProfile(user); + const profile = getLocalProfile(user, withEmail); if (!profile) { throw new LocalProfileNotSetError(); } @@ -544,7 +552,8 @@ export async function updateUserPassword( mongo: Db, tenantID: string, id: string, - password: string + password: string, + passwordID: string ) { // Hash the password. const hashedPassword = await hashPassword(password); @@ -556,10 +565,17 @@ export async function updateUserPassword( id, // This ensures that the document we're updating already has a local // profile associated with them. - "profiles.type": "local", + profiles: { + $elemMatch: { + type: "local", + passwordID, + }, + }, }, { $set: { + // Update the passwordID with a new one. + "profiles.$[profiles].passwordID": uuid(), "profiles.$[profiles].password": hashedPassword, }, }, @@ -576,10 +592,15 @@ export async function updateUserPassword( throw new UserNotFoundError(id); } - if (!hasLocalProfile(user)) { + const profile = getLocalProfile(user); + if (!profile) { throw new LocalProfileNotSetError(); } + if (profile.passwordID !== passwordID) { + throw new Error("passwordID mismatch"); + } + throw new Error("an unexpected error occurred"); } @@ -936,6 +957,7 @@ export async function setUserLocalProfile( type: "local", id: email, password: hashedPassword, + passwordID: uuid(), }; // The profile wasn't found, so add it to the User. @@ -1553,6 +1575,7 @@ export async function resetUserPassword( tenantID: string, id: string, password: string, + passwordID: string, resetID: string ) { // Hash the password. @@ -1568,12 +1591,15 @@ export async function resetUserPassword( profiles: { $elemMatch: { type: "local", + passwordID, resetID, }, }, }, { $set: { + // Update the passwordID with a new one. + "profiles.$[profiles].passwordID": uuid(), "profiles.$[profiles].password": hashedPassword, }, $unset: { @@ -1602,6 +1628,10 @@ export async function resetUserPassword( throw new PasswordResetTokenExpired("reset id mismatch"); } + if (profile.passwordID !== passwordID) { + throw new PasswordResetTokenExpired("password id mismatch"); + } + throw new Error("an unexpected error occurred"); } diff --git a/src/core/server/services/users/auth/invite.ts b/src/core/server/services/users/auth/invite.ts index f650c79eb..7e5eb161e 100644 --- a/src/core/server/services/users/auth/invite.ts +++ b/src/core/server/services/users/auth/invite.ts @@ -306,6 +306,7 @@ export async function redeem( id: email, type: "local", password, + passwordID: uuid.v4(), }; // Create the new user based on the invite. diff --git a/src/core/server/services/users/auth/reset.ts b/src/core/server/services/users/auth/reset.ts index 1f8f9ba8a..0ad833b25 100644 --- a/src/core/server/services/users/auth/reset.ts +++ b/src/core/server/services/users/auth/reset.ts @@ -116,7 +116,7 @@ export async function verifyResetTokenString( signingConfig: JWTSigningConfig, tokenString: string, now: Date -): Promise { +) { const token = verifyJWT(tokenString, signingConfig, now, { // Verify that the token is for this Tenant. issuer: tenant.id, @@ -144,20 +144,20 @@ export async function verifyResetTokenString( } // Get the local profile that might contain the reset token. - const localProfile = getLocalProfile(user); - if (!localProfile) { + const profile = getLocalProfile(user); + if (!profile) { throw new LocalProfileNotSetError(); } // Verify that the reset id matches the current user. - if (localProfile.resetID !== resetID) { + if (profile.resetID !== resetID) { throw new PasswordResetTokenExpired("reset id mismatch"); } // Now that we've verified that the token is valid and it has not expired, // checked that the reset id matches the current one set on the user, we can // be confident that the passed reset token is valid. - return token; + return { token, user, profile }; } export async function resetPassword( @@ -172,7 +172,10 @@ export async function resetPassword( validatePassword(password); // Verify that the password reset token is valid and unpack it. - const { sub: userID, rid: resetID } = await verifyResetTokenString( + const { + token: { sub: userID, rid: resetID }, + profile: { passwordID }, + } = await verifyResetTokenString( mongo, tenant, signingConfig, @@ -186,6 +189,7 @@ export async function resetPassword( tenant.id, userID, password, + passwordID, resetID ); diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index b36ff7e6e..8594614f1 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -8,6 +8,7 @@ import { EmailNotSetError, LocalProfileAlreadySetError, LocalProfileNotSetError, + PasswordIncorrect, TokenNotFoundError, UserAlreadyBannedError, UserAlreadySuspendedError, @@ -41,6 +42,7 @@ import { updateUserRole, updateUserUsername, User, + verifyUserPassword, } from "coral-server/models/user"; import { getLocalProfile, @@ -229,8 +231,12 @@ export async function updatePassword( mailer: MailerQueue, tenant: Tenant, user: User, - password: string + oldPassword: string, + newPassword: string ) { + // Validate that the new password is valid. + validatePassword(newPassword); + // We require that the email address for the user be defined for this method. if (!user.email) { throw new EmailNotSetError(); @@ -238,17 +244,30 @@ export async function updatePassword( // We also don't allow this method to be used by users that don't have a local // profile already. - if (!hasLocalProfile(user, user.email)) { + const profile = getLocalProfile(user, user.email); + if (!profile) { throw new LocalProfileNotSetError(); } - validatePassword(password); + // Verify that the old password is correct. We'll be using the profile's + // passwordID to ensure we prevent a race. + const passwordVerified = await verifyUserPassword( + user, + oldPassword, + user.email + ); + if (!passwordVerified) { + // We throw a PasswordIncorrect error here instead of an + // InvalidCredentialsError because the current user is already signed in. + throw new PasswordIncorrect(); + } const updatedUser = await updateUserPassword( mongo, tenant.id, user.id, - password + newPassword, + profile.passwordID ); // If the user has an email address associated with their account, send them diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 3cbe52718..c889ad202 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -165,6 +165,13 @@ profile-settings-description = profile-settings-empty = You are not currently ignoring anyone profile-settings-stopIgnoring = Stop ignoring +profile-settings-changePassword = Change Password +profile-settings-changePassword-oldPassword = Old Password +profile-settings-changePassword-forgotPassword = Forgot your password? +profile-settings-changePassword-newPassword = New Password +profile-settings-changePassword-button = Change Password +profile-settings-changePassword-updated = + Your password has been updated ## Report Comment Popover comments-reportPopover =