[CORL-445] Change Password (#2426)

* fix: reversed `new-password` autocomplete option

* feat: initial implementation

* fix: localization and testing

* fix: updated snapshot
This commit is contained in:
Wyatt Johnson
2019-08-01 22:08:14 +00:00
committed by GitHub
parent 290ceee8e9
commit 836a2267bf
48 changed files with 1122 additions and 197 deletions
+1 -1
View File
@@ -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
}
@@ -32,7 +32,6 @@ const ClientSecretField: FunctionComponent<Props> = ({
<>
<PasswordField
disabled={disabled || meta.submitting}
autoComplete="new-password"
// TODO: (wyattjoh) figure out how to add translations to these props
hidePasswordTitle="Show Client Secret"
showPasswordTitle="Hide Client Secret"
@@ -145,7 +145,6 @@ const SMTP: FunctionComponent<Props> = ({ disabled }) => (
<>
<PasswordField
id={input.name}
autoComplete="new-password"
disabled={disabled || !enabled}
fullWidth
color={colorFromMeta(meta)}
@@ -32,7 +32,6 @@ const APIKeyField: FunctionComponent<Props> = ({
<PasswordField
id={`configure-moderation-${input.name}`}
disabled={disabled}
autoComplete="new-password"
// TODO: (wyattjoh) figure out how to add translations to these props
hidePasswordTitle="Show API Key"
showPasswordTitle="Hide API Key"
@@ -193,7 +193,7 @@ needs to be displayed, e.g. “Log in with &lt;Facebook&gt;”.
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={true}
@@ -638,7 +638,7 @@ For more information visit:
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={false}
@@ -978,7 +978,7 @@ needs to be displayed, e.g. “Log in with &lt;Facebook&gt;”.
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={false}
@@ -1480,7 +1480,7 @@ needs to be displayed, e.g. “Log in with &lt;Facebook&gt;”.
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={false}
@@ -2409,7 +2409,7 @@ needs to be displayed, e.g. “Log in with &lt;Facebook&gt;”.
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={true}
@@ -3098,7 +3098,7 @@ to create and set up a web application. For more information visit:
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={true}
@@ -3404,7 +3404,7 @@ For more information visit:
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={true}
@@ -521,7 +521,7 @@ improve the API over time.
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={false}
@@ -712,7 +712,7 @@ in your Akismet account:
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-input"
disabled={false}
+1
View File
@@ -12,6 +12,7 @@ it("renders sign in", () => {
const props: PropTypesOf<typeof AppN> = {
view: "SIGN_IN",
auth: {},
viewer: null,
};
const renderer = createRenderer();
renderer.render(<AppN {...props} />);
+5 -4
View File
@@ -23,18 +23,19 @@ export type View =
export interface AppProps {
view: View;
viewer: PropTypesOf<typeof ForgotPassword>["viewer"];
auth: PropTypesOf<typeof SignInContainer>["auth"] &
PropTypesOf<typeof SignUpContainer>["auth"];
}
const renderView = (view: AppProps["view"], auth: AppProps["auth"]) => {
const render = ({ view, auth, viewer }: AppProps) => {
switch (view) {
case "SIGN_UP":
return <SignUpContainer auth={auth} />;
case "SIGN_IN":
return <SignInContainer auth={auth} />;
case "FORGOT_PASSWORD":
return <ForgotPassword />;
return <ForgotPassword viewer={viewer} />;
case "CREATE_USERNAME":
return <CreateUsername />;
case "CREATE_PASSWORD":
@@ -46,10 +47,10 @@ const renderView = (view: AppProps["view"], auth: AppProps["auth"]) => {
}
};
const App: FunctionComponent<AppProps> = ({ view, auth }) => (
const App: FunctionComponent<AppProps> = props => (
<>
{process.env.NODE_ENV !== "test" && <ViewRouter />}
<div>{renderView(view, auth)}</div>
<div>{render(props)}</div>
</>
);
+10 -1
View File
@@ -25,9 +25,17 @@ class AppContainer extends Component<Props> {
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 <App view={view} auth={auth} viewer={viewer} />;
}
return (
<AccountCompletionContainer auth={auth} viewer={viewer}>
<App view={view} auth={auth} />
<App view={view} auth={auth} viewer={viewer} />
</AccountCompletionContainer>
);
}
@@ -51,6 +59,7 @@ const enhanced = withLocalStateContainer(
viewer: graphql`
fragment AppContainer_viewer on User {
...AccountCompletionContainer_viewer
...ForgotPasswordContainer_viewer
}
`,
})(AppContainer)
@@ -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<string | null>(null);
return checkEmail ? (
<CheckEmail email={checkEmail} />
) : (
<ForgotPasswordForm onCheckEmail={setCheckEmail} />
);
};
export default ForgotPassword;
@@ -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<Props> = ({ viewer }) => {
const [checkEmail, setCheckEmail] = useState<string | null>(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 ? (
<CheckEmail email={checkEmail} />
) : (
<ForgotPasswordForm email={email} onCheckEmail={setCheckEmail} />
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment ForgotPasswordContainer_viewer on User {
email
}
`,
})(ForgotPasswordContainer);
export default enhanced;
@@ -35,20 +35,22 @@ interface FormProps {
}
interface Props {
email: string | null;
onCheckEmail: (email: string) => void;
}
const ForgotPasswordForm: FunctionComponent<Props> = ({ onCheckEmail }) => {
const ForgotPasswordForm: FunctionComponent<Props> = ({
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<Props> = ({ onCheckEmail }) => {
<Title>Forgot Password?</Title>
</Localized>
</Bar>
<SubBar>
<Typography variant="bodyCopy" container={Flex}>
<Localized id="forgotPassword-gotBackToSignIn">
<TextLink href={signInHref} onClick={onGotoSignIn}>
Go back to sign in page
</TextLink>
</Localized>
</Typography>
</SubBar>
{/* If an email address has been provided, then they are already logged in. */}
{!email && (
<SubBar>
<Typography variant="bodyCopy" container={Flex}>
<Localized id="forgotPassword-gotBackToSignIn">
<TextLink href={signInHref} onClick={onGotoSignIn}>
Go back to sign in page
</TextLink>
</Localized>
</Typography>
</SubBar>
)}
<Main data-testid="forgotPassword-main">
<Form onSubmit={onSubmit}>
<Form onSubmit={onSubmit} initialValues={{ email }}>
{({ handleSubmit, submitting, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<AutoHeight />
@@ -1 +1,4 @@
export { default, default as ForgotPassword } from "./ForgotPassword";
export {
default,
default as ForgotPasswordContainer,
} from "./ForgotPasswordContainer";
@@ -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);
@@ -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);
@@ -21,7 +21,8 @@ export interface ProfileProps {
viewer: PropTypesOf<typeof UserBoxContainer>["viewer"] &
PropTypesOf<typeof CommentHistoryContainer>["viewer"] &
PropTypesOf<typeof SettingsContainer>["viewer"];
settings: PropTypesOf<typeof UserBoxContainer>["settings"];
settings: PropTypesOf<typeof UserBoxContainer>["settings"] &
PropTypesOf<typeof SettingsContainer>["settings"];
}
const Profile: FunctionComponent<ProfileProps> = props => {
@@ -58,7 +59,7 @@ const Profile: FunctionComponent<ProfileProps> = props => {
<CommentHistoryContainer viewer={props.viewer} story={props.story} />
</TabPane>
<TabPane tabID="SETTINGS">
<SettingsContainer viewer={props.viewer} />
<SettingsContainer viewer={props.viewer} settings={props.settings} />
</TabPane>
</TabContent>
</HorizontalGutter>
@@ -41,6 +41,7 @@ const enhanced = withFragmentContainer<ProfileContainerProps>({
settings: graphql`
fragment ProfileContainer_settings on Settings {
...UserBoxContainer_settings
...SettingsContainer_settings
}
`,
})(ProfileContainer);
@@ -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<Props> = ({ 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 (
<div data-testid="profile-settings-changePassword">
<Form onSubmit={onSubmit}>
{({
handleSubmit,
submitting,
submitError,
pristine,
submitSucceeded,
}) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="oneAndAHalf">
<Localized id="profile-settings-changePassword">
<Typography variant="heading3">Change Password</Typography>
</Localized>
<HorizontalGutter container={<FieldSet />}>
<Field
name="oldPassword"
validate={composeValidators(required, validatePassword)}
>
{({ input, meta }) => (
<FormField container={<FieldSet />}>
<Localized id="profile-settings-changePassword-oldPassword">
<InputLabel htmlFor={input.name}>
Old Password
</InputLabel>
</Localized>
<PasswordField
fullWidth
id={input.name}
disabled={submitting}
color={colorFromMeta(meta)}
autoComplete="current-password"
{...input}
/>
<ValidationMessage fullWidth meta={meta} />
<Flex justifyContent="flex-end">
<Localized id="profile-settings-changePassword-forgotPassword">
<Typography variant="bodyCopy">
<Button
variant="underlined"
color="primary"
onClick={onResetPassword}
>
Forgot your password?
</Button>
</Typography>
</Localized>
</Flex>
</FormField>
)}
</Field>
<Field
name="newPassword"
validate={composeValidators(required, validatePassword)}
>
{({ input, meta }) => (
<FormField container={<FieldSet />}>
<Localized id="profile-settings-changePassword-newPassword">
<InputLabel htmlFor={input.name}>
New Password
</InputLabel>
</Localized>
<PasswordField
fullWidth
id={input.name}
disabled={submitting}
color={colorFromMeta(meta)}
autoComplete="new-password"
{...input}
/>
<ValidationMessage fullWidth meta={meta} />
</FormField>
)}
</Field>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
{submitSucceeded && (
<Localized id="profile-settings-changePassword-updated">
<CallOut color="success" fullWidth>
Your password has been updated
</CallOut>
</Localized>
)}
<Flex justifyContent="flex-end">
<Localized id="profile-settings-changePassword-button">
<Button
color="primary"
variant="filled"
type="submit"
disabled={submitting || pristine}
>
Change Password
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default ChangePassword;
@@ -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<Props> = ({
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 (
<>
<Popup
href={`${urls.embed.auth}?view=${view}`}
title="Coral Auth"
features="menubar=0,resizable=0,width=350,height=450,top=100,left=500"
open={open}
focus={focus}
onFocus={onFocus}
onBlur={onBlur}
onClose={onClose}
/>
<ChangePassword onResetPassword={onResetPassword} />
</>
);
};
const enhanced = withLocalStateContainer(
graphql`
fragment ChangePasswordContainerLocal on Local {
authPopup {
open
focus
view
}
}
`
)(
withFragmentContainer<Props>({
settings: graphql`
fragment ChangePasswordContainer_settings on Settings {
auth {
integrations {
local {
enabled
targetFilter {
stream
}
}
}
}
}
`,
})(ChangePasswordContainer)
);
export default enhanced;
@@ -1,7 +1,3 @@
.root {
max-width: 440px;
}
.description {
color: var(--palette-text-primary);
font-family: var(--font-family-sans-serif);
@@ -23,10 +23,7 @@ interface Props {
const IgnoreUserSettingsContainer: FunctionComponent<Props> = ({ viewer }) => {
const removeUserIgnore = useMutation(RemoveUserIgnoreMutation);
return (
<div
className={styles.root}
data-testid="profile-settings-ignoredCommenters"
>
<div data-testid="profile-settings-ignoredCommenters">
<Localized id="profile-settings-ignoredCommenters">
<Typography variant="heading3">Ignored Commenters</Typography>
</Localized>
@@ -0,0 +1,3 @@
.root {
max-width: 440px;
}
@@ -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<Props> = ({ viewer }) => {
return <IgnoreUserSettingsContainer viewer={viewer} />;
};
const SettingsContainer: FunctionComponent<Props> = ({ viewer, settings }) => (
<HorizontalGutter spacing={5} className={styles.root}>
<IgnoreUserSettingsContainer viewer={viewer} />
<ChangePasswordContainer settings={settings} />
</HorizontalGutter>
);
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
@@ -20,6 +29,11 @@ const enhanced = withFragmentContainer<Props>({
...IgnoreUserSettingsContainer_viewer
}
`,
settings: graphql`
fragment SettingsContainer_settings on Settings {
...ChangePasswordContainer_settings
}
`,
})(SettingsContainer);
export default enhanced;
@@ -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<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdatePasswordMutation($input: UpdatePasswordInput!) {
updatePassword(input: $input) {
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdatePasswordMutation;
+13
View File
@@ -93,6 +93,19 @@ export const settings = createFixture<GQLSettings>({
},
});
export const settingsWithoutLocalAuth = createFixture<GQLSettings>(
{
auth: {
integrations: {
local: {
enabled: false,
},
},
},
},
settings
);
export const baseUser = createFixture<GQLUser>({
createdAt: "2018-02-06T18:24:00.000Z",
status: {
@@ -0,0 +1,337 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders the empty settings pane 1`] = `
<div
className="Box-root HorizontalGutter-root App-root HorizontalGutter-full"
>
<ul
className="TabBar-root TabBar-primary coral coral-tabBar"
role="tablist"
>
<li
className="Tab-root"
id="tab-COMMENTS"
role="presentation"
>
<button
aria-controls="tabPane-COMMENTS"
aria-selected={false}
className="BaseButton-root Tab-button Tab-primary coral coral-tabBar-allComments"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
role="tab"
type="button"
>
<span>
Comments
</span>
</button>
</li>
<li
className="Tab-root"
id="tab-PROFILE"
role="presentation"
>
<button
aria-controls="tabPane-PROFILE"
aria-selected={true}
className="BaseButton-root Tab-button Tab-primary Tab-active coral coral-tabBar-myProfile"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
role="tab"
type="button"
>
<span>
My Profile
</span>
</button>
</li>
</ul>
<section
aria-labelledby="tab-PROFILE"
className="App-tabContent"
data-testid="current-tab-pane"
id="tabPane-PROFILE"
role="tabpanel"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
>
<div
className="Box-root Flex-root"
>
<div
className="Flex-flex Flex-halfItemGutter Flex-wrap gutter"
>
<div
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Signed in as
<span
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary"
>
Passivo
</span>
.
</div>
<div
className="Box-root Flex-root Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary Flex-flex"
>
<span>
Not you? 
</span>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Sign Out
</button>
</div>
</div>
</div>
<ul
className="TabBar-root TabBar-secondary"
role="tablist"
>
<li
className="Tab-root"
id="tab-MY_COMMENTS"
role="presentation"
>
<button
aria-controls="tabPane-MY_COMMENTS"
aria-selected={false}
className="BaseButton-root Tab-button Tab-secondary"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
role="tab"
type="button"
>
<span>
My Comments
</span>
</button>
</li>
<li
className="Tab-root"
id="tab-SETTINGS"
role="presentation"
>
<button
aria-controls="tabPane-SETTINGS"
aria-selected={true}
className="BaseButton-root Tab-button Tab-secondary Tab-active"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
role="tab"
type="button"
>
<span>
Settings
</span>
</button>
</li>
</ul>
<section
aria-labelledby="tab-SETTINGS"
id="tabPane-SETTINGS"
role="tabpanel"
>
<div
className="Box-root HorizontalGutter-root SettingsContainer-root HorizontalGutter-spacing-5"
>
<div
data-testid="profile-settings-ignoredCommenters"
>
<h1
className="Box-root Typography-root Typography-heading3 Typography-colorTextPrimary"
>
Ignored Commenters
</h1>
<p
className="IgnoreUserSettingsContainer-description"
>
Once you ignore someone, all of their comments are hidden from you.
Commenters you ignore will still be able to see your comments.
</p>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-1"
>
<div
className="IgnoreUserSettingsContainer-empty"
>
You are not currently ignoring anyone
</div>
</div>
</div>
<div
data-testid="profile-settings-changePassword"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<h1
className="Box-root Typography-root Typography-heading3 Typography-colorTextPrimary"
>
Change Password
</h1>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-full"
>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="oldPassword"
>
Old Password
</label>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="current-password"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
disabled={false}
id="oldPassword"
name="oldPassword"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
role="button"
tabIndex={0}
title="Hide password"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</span>
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Forgot your password?
</p>
</div>
</fieldset>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="newPassword"
>
New Password
</label>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
disabled={false}
id="newPassword"
name="newPassword"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
role="button"
tabIndex={0}
title="Hide password"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</span>
</div>
</div>
</div>
</fieldset>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-disabled"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Change Password
</button>
</div>
</fieldset>
</div>
</form>
</div>
</div>
</section>
</div>
</section>
</div>
`;
@@ -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<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
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<GQLResolver>({
Query: {
viewer: () =>
pureMerge<typeof viewer>(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!);
});
@@ -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<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
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<GQLResolver>({
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<GQLResolver>({
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<GQLResolver>({
Query: {
viewer: () =>
pureMerge<typeof viewer>(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!);
});
@@ -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%;
@@ -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<CallOutProps> = props => {
[classes.colorRegular]: color === "regular",
[classes.colorError]: color === "error",
[classes.colorPrimary]: color === "primary",
[classes.colorSuccess]: color === "success",
[classes.fullWidth]: fullWidth,
},
className
+6
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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();
});
+8
View File
@@ -651,3 +651,11 @@ export class LiveUpdatesDisabled extends CoralError {
});
}
}
export class PasswordIncorrect extends CoralError {
constructor() {
super({
code: ERROR_CODES.PASSWORD_INCORRECT,
});
}
}
+1
View File
@@ -49,4 +49,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
INVITE_TOKEN_EXPIRED: "error-inviteTokenExpired",
INVITE_REQUIRES_EMAIL_ADDRESSES: "error-inviteRequiresEmailAddresses",
LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled",
PASSWORD_INCORRECT: "error-passwordIncorrect",
};
@@ -74,8 +74,9 @@ const primeCommentsFromConnection = (ctx: Context) => (
*/
const mapVisibleComment = (user?: Pick<User, "role">) => {
// 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<Comment> | null) => {
@@ -103,7 +104,7 @@ const mapVisibleComments = (user?: Pick<User, "role">) => (
): Array<Readonly<Comment> | null> => comments.map(mapVisibleComment(user));
export default (ctx: Context) => ({
comment: new DataLoader(
comment: new DataLoader<string, Readonly<Comment> | null>(
(ids: string[]) =>
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids).then(
mapVisibleComments(ctx.user)
+10 -6
View File
@@ -95,12 +95,16 @@ export const Users = (ctx: TenantContext) => ({
updatePassword: async (
input: GQLUpdatePasswordInput
): Promise<Readonly<User> | 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(
@@ -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<GQLMutationTypeResolver<void>> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comments.edit(input),
@@ -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.
+1
View File
@@ -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.
+10 -1
View File
@@ -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)
+14 -7
View File
@@ -34,11 +34,22 @@ export function needsSSOUpdate(
* @param user the User to pull the LocalProfile out of
*/
export function getLocalProfile(
user: Pick<User, "profiles">
user: Pick<User, "profiles">,
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<User, "profiles">,
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;
}
+35 -5
View File
@@ -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<User, "profiles">,
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");
}
@@ -306,6 +306,7 @@ export async function redeem(
id: email,
type: "local",
password,
passwordID: uuid.v4(),
};
// Create the new user based on the invite.
+10 -6
View File
@@ -116,7 +116,7 @@ export async function verifyResetTokenString(
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
): Promise<ResetToken> {
) {
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
);
+23 -4
View File
@@ -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
+7
View File
@@ -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 =