mirror of
https://github.com/wassname/talk.git
synced 2026-07-16 11:22:16 +08:00
[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:
Vendored
+1
-1
@@ -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 <Facebook>”.
|
||||
>
|
||||
<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 <Facebook>”.
|
||||
>
|
||||
<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 <Facebook>”.
|
||||
>
|
||||
<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 <Facebook>”.
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -651,3 +651,11 @@ export class LiveUpdatesDisabled extends CoralError {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordIncorrect extends CoralError {
|
||||
constructor() {
|
||||
super({
|
||||
code: ERROR_CODES.PASSWORD_INCORRECT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user