diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index cfc41676b..c8b8f6bb6 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -7,4 +7,8 @@ export { default as excludeIf } from './excludeIf'; export { default as connect } from './connect'; export { default as withMergedSettings } from './withMergedSettings'; export { default as withSignIn } from './withSignIn'; +export { default as withSignUp } from './withSignUp'; export { default as withForgotPassword } from './withForgotPassword'; +export { + default as withResendEmailConfirmation, +} from './withResendEmailConfirmation'; diff --git a/client/coral-framework/hocs/withForgotPassword.js b/client/coral-framework/hocs/withForgotPassword.js index c8b42b8c5..fb8eb434c 100644 --- a/client/coral-framework/hocs/withForgotPassword.js +++ b/client/coral-framework/hocs/withForgotPassword.js @@ -11,6 +11,7 @@ export default hoistStatics(WrappedComponent => { static contextTypes = { store: PropTypes.object, rest: PropTypes.func, + pym: PropTypes.object, }; state = { @@ -19,9 +20,11 @@ export default hoistStatics(WrappedComponent => { success: false, }; - forgotPassword = email => { + forgotPassword = (email, redirectUri) => { + if (!redirectUri) { + redirectUri = this.context.pym.parentUrl || location.href; + } const { rest } = this.context; - const redirectUri = location.href; this.setState({ loading: true, error: null, success: false }); rest('/account/password/reset', { diff --git a/client/coral-framework/hocs/withResendEmailConfirmation.js b/client/coral-framework/hocs/withResendEmailConfirmation.js new file mode 100644 index 000000000..6b5f8375d --- /dev/null +++ b/client/coral-framework/hocs/withResendEmailConfirmation.js @@ -0,0 +1,65 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; +import { translateError } from '../utils'; + +/** + * WithResendEmailConfirmaton provides properties `forgotPasssword`, `loading`, `errorMessage`, `success`. + */ +export default hoistStatics(WrappedComponent => { + class WithResendEmailConfirmaton extends React.Component { + static contextTypes = { + store: PropTypes.object, + rest: PropTypes.func, + pym: PropTypes.object, + }; + + state = { + error: null, + loading: false, + success: false, + }; + + resendEmailConfirmation = (email, redirectUri) => { + if (!redirectUri) { + redirectUri = this.context.pym.parentUrl || location.href; + } + const { rest } = this.context; + this.setState({ loading: true, error: null, success: false }); + + rest('/users/resend-verify', { + method: 'POST', + body: { email }, + headers: { 'X-Pym-Url': redirectUri }, + }) + .then(() => { + this.setState({ loading: false, error: null, success: true }); + }) + .catch(error => { + console.error(error); + this.setState({ loading: false, error }); + }); + }; + + getErrorMessage() { + if (!this.state.error) { + return ''; + } + return translateError(this.state.error); + } + + render() { + return ( + + ); + } + } + + return WithResendEmailConfirmaton; +}); diff --git a/client/coral-framework/hocs/withSignIn.js b/client/coral-framework/hocs/withSignIn.js index fa70138f9..53c3ab6d1 100644 --- a/client/coral-framework/hocs/withSignIn.js +++ b/client/coral-framework/hocs/withSignIn.js @@ -6,7 +6,13 @@ import { translateError } from '../utils'; import { t } from '../services/i18n'; /** - * WithSignIn provides properties `signIn`, `loading`, `errorMessage`, `requireRecaptcha`, 'success'. + * WithSignIn provides properties + * `signIn` + * `loading` + * `errorMessage` + * `requireRecaptcha` + * `requireEmailConfirmation` + * 'success' */ export default hoistStatics(WrappedComponent => { class WithSignIn extends React.Component { @@ -20,6 +26,7 @@ export default hoistStatics(WrappedComponent => { loading: false, success: false, requireRecaptcha: false, + requireEmailConfirmation: false, }; signIn = (email, password, recaptchaResponse) => { @@ -51,6 +58,8 @@ export default hoistStatics(WrappedComponent => { if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { changeSet.requireRecaptcha = !!this.context.store.getState().config .static.TALK_RECAPTCHA_PUBLIC; + } else if (error.translation_key === 'EMAIL_NOT_VERIFIED') { + changeSet.requireEmailConfirmation = true; } this.setState(changeSet); }); @@ -73,6 +82,7 @@ export default hoistStatics(WrappedComponent => { loading={this.state.loading} errorMessage={this.getErrorMessage()} requireRecaptcha={this.state.requireRecaptcha} + requireEmailConfirmation={this.state.requireEmailConfirmation} success={this.state.success} /> ); diff --git a/client/coral-framework/hocs/withSignUp.js b/client/coral-framework/hocs/withSignUp.js new file mode 100644 index 000000000..8274b3b8a --- /dev/null +++ b/client/coral-framework/hocs/withSignUp.js @@ -0,0 +1,118 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import { compose, gql } from 'react-apollo'; +import PropTypes from 'prop-types'; +import { translateError } from '../utils'; +import validate from '../helpers/validate'; +import errorMsg from 'coral-framework/helpers/error'; +import t from '../services/i18n'; +import withQuery from './withQuery'; +import get from 'lodash/get'; + +const requiredFields = ['username', 'email', 'password']; +const allFields = requiredFields; + +const QUERY = gql` + query TalkFramework_WithSignUpQuery { + settings { + requireEmailConfirmation + } + } +`; + +export const withSettingsQuery = withQuery(QUERY); + +/** + * withSignUp provides properties `signUp`, `loading`, `errorMessage`, `requireEmailVerification`, 'success', 'validate'. + */ +const withSignUp = hoistStatics(WrappedComponent => { + class WithSignUp extends React.Component { + static contextTypes = { + store: PropTypes.object, + rest: PropTypes.func, + pym: PropTypes.object, + }; + + static propTypes = { + root: PropTypes.object.isRequired, + }; + + state = { + error: null, + loading: false, + success: false, + }; + + validate = (field, value) => { + if (!allFields.includes(field)) { + return ''; + } + + if (requiredFields.includes(field) && !value) { + return t('sign_in.required_field'); + } + + if (field in validate) { + return validate[field](value) ? '' : errorMsg[field]; + } + + return ''; + }; + + signUp = ({ username, email, password }, redirectUri) => { + if (!redirectUri) { + redirectUri = this.context.pym.parentUrl || location.href; + } + + const { rest } = this.context; + const params = { + method: 'POST', + body: { + username, + email, + password, + }, + headers: { 'X-Pym-Url': redirectUri }, + }; + + rest('/users', params) + .then(() => { + this.setState({ success: true, loading: false, error: null }); + }) + .catch(error => { + if (!error.status || error.status !== 401) { + console.error(error); + } + const changeSet = { success: false, loading: false, error }; + this.setState(changeSet); + }); + }; + + getErrorMessage() { + if (!this.state.error) { + return ''; + } + return translateError(this.state.error); + } + + render() { + return ( + + ); + } + } + + return WithSignUp; +}); + +export default compose(withSettingsQuery, withSignUp); diff --git a/plugins/talk-plugin-auth/client/login/components/ForgotPassword.js b/plugins/talk-plugin-auth/client/login/components/ForgotPassword.js index 84daa04b7..12a309c28 100644 --- a/plugins/talk-plugin-auth/client/login/components/ForgotPassword.js +++ b/plugins/talk-plugin-auth/client/login/components/ForgotPassword.js @@ -5,8 +5,6 @@ import { Button, TextField } from 'plugin-api/beta/client/components/ui'; import t from 'coral-framework/services/i18n'; class ForgotPassword extends React.Component { - state = { value: '' }; - handleSignUpLink = e => { e.preventDefault(); this.props.onSignUpLink(); diff --git a/plugins/talk-plugin-auth/client/login/components/Main.js b/plugins/talk-plugin-auth/client/login/components/Main.js index 1aed0f229..992d7a2fd 100644 --- a/plugins/talk-plugin-auth/client/login/components/Main.js +++ b/plugins/talk-plugin-auth/client/login/components/Main.js @@ -3,7 +3,9 @@ import { Dialog } from 'plugin-api/beta/client/components/ui'; import styles from './Main.css'; import PropTypes from 'prop-types'; import SignIn from '../containers/SignIn'; +import SignUp from '../containers/SignUp'; import ForgotPassword from '../containers/ForgotPassword'; +import ResendEmailConfirmation from '../containers/ResendEmailConfirmation'; import * as views from '../enums/views'; const Main = ({ view, onResetView }) => ( @@ -14,7 +16,9 @@ const Main = ({ view, onResetView }) => ( )} {view === views.SIGN_IN && } + {view === views.SIGN_UP && } {view === views.FORGOT_PASSWORD && } + {view === views.RESEND_EMAIL_CONFIRMATION && } ); diff --git a/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.css b/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.css new file mode 100644 index 000000000..dc59a9ff1 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.css @@ -0,0 +1,15 @@ +.header { + margin-bottom: 20px; + text-align: center; + font-size: 1.2em; +} + +.notVerified { + padding: 10px; + margin-bottom: 20px; + border-radius: 2px; + border: solid 1px #dddd00; + background: #FFFF9C; + color: #777700; +} + diff --git a/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.js b/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.js new file mode 100644 index 000000000..082492128 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { + Button, + Spinner, + Success, + Alert, +} from 'plugin-api/beta/client/components/ui'; +import PropTypes from 'prop-types'; +import styles from './ResendEmailConfirmation.css'; +import t from 'coral-framework/services/i18n'; + +class ResendVerification extends React.Component { + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + render() { + const { email, errorMessage, loading, success } = this.props; + return ( +
+

{t('sign_in.email_verify_cta')}

+ + {errorMessage && {errorMessage}} +
+ {t('error.email_not_verified', email)} +
+
+ {!loading && + !success && ( + + )} + {loading && } + {success && } +
+
+ ); + } +} + +ResendVerification.propTypes = { + success: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + email: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string.isRequired, +}; + +export default ResendVerification; diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.css b/plugins/talk-plugin-auth/client/login/components/SignUp.css new file mode 100644 index 000000000..057713757 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.css @@ -0,0 +1,49 @@ +.header { + margin-bottom: 20px; +} + +.header h1, .separator h1{ + text-align: center; + font-size: 1.2em; +} + +.socialConnections { + margin-bottom: 20px; +} + +.separator h1{ + text-align: center; + font-size: 1.2em; +} + +.hint { + color: grey; + font-weight: 600; + padding: 3px 0 16px; +} + +.action { + margin-top: 0px; +} + +.signInButton { + margin-top: 10px; + background-color: #2a2a2a; +} + +.footer { + margin: 20px auto 10px; + text-align: center; +} + +.footer span { + display: block; + margin-bottom: 5px; +} + +.footer a { + color: #2c69b6; + cursor: pointer; + margin: 0 5px; +} + diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.js b/plugins/talk-plugin-auth/client/login/components/SignUp.js new file mode 100644 index 000000000..014d0db89 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.js @@ -0,0 +1,168 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + TextField, + Spinner, + Success, + Alert, +} from 'plugin-api/beta/client/components/ui'; +import t from 'coral-framework/services/i18n'; +import styles from './SignUp.css'; + +class SignUp extends React.Component { + handleSignInLink = e => { + e.preventDefault(); + this.props.onSignInLink(); + }; + + handleUsernameChange = e => this.props.onUsernameChange(e.target.value); + handleEmailChange = e => this.props.onEmailChange(e.target.value); + handlePasswordChange = e => this.props.onPasswordChange(e.target.value); + handlePasswordRepeatChange = e => + this.props.onPasswordRepeatChange(e.target.value); + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + render() { + const { + username, + email, + password, + passwordRepeat, + usernameError, + emailError, + passwordError, + passwordRepeatError, + loading, + errorMessage, + requireEmailConfirmation, + success, + } = this.props; + + return ( +
+
+

{t('sign_in.sign_up')}

+
+ + {errorMessage && {errorMessage}} + {!success && ( +
+
Social
+
+

{t('sign_in.or')}

+
+
+ + + + {passwordError && ( + + {' '} + Password must be at least 8 characters.{' '} + + )} + +
+ + {loading && } +
+ +
+ )} + {success && ( +
+ + {requireEmailConfirmation && ( +

+ {t('sign_in.verify_email')} +
+
+ {t('sign_in.verify_email2')} +

+ )} +
+ )} +
+ {t('sign_in.already_have_an_account')}{' '} + + {t('sign_in.sign_in')} + +
+
+ ); + } +} + +SignUp.propTypes = { + loading: PropTypes.bool.isRequired, + username: PropTypes.string.isRequired, + usernameError: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + emailError: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + passwordError: PropTypes.string.isRequired, + passwordRepeat: PropTypes.string.isRequired, + passwordRepeatError: PropTypes.string.isRequired, + onUsernameChange: PropTypes.func.isRequired, + onEmailChange: PropTypes.func.isRequired, + onPasswordChange: PropTypes.func.isRequired, + onPasswordRepeatChange: PropTypes.func.isRequired, + onSignInLink: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string.isRequired, + requireEmailConfirmation: PropTypes.bool.isRequired, + success: PropTypes.bool.isRequired, +}; + +export default SignUp; diff --git a/plugins/talk-plugin-auth/client/login/containers/Main.js b/plugins/talk-plugin-auth/client/login/containers/Main.js index 3391fe1f4..b75030e0b 100644 --- a/plugins/talk-plugin-auth/client/login/containers/Main.js +++ b/plugins/talk-plugin-auth/client/login/containers/Main.js @@ -11,6 +11,22 @@ class MainContainer extends React.Component { this.props.setView(views.SIGN_IN); }; + resizeHeight() { + setTimeout(() => { + const height = document.getElementById('signInDialog').offsetHeight + 100; + window.resizeTo(500, height); + }, 20); + } + + componentDidMount() { + this.resizeHeight(); + } + + componentDidUpdate(prevProps) { + if (prevProps.view !== this.props.view) { + this.resizeHeight(); + } + } render() { return
; } diff --git a/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js b/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js new file mode 100644 index 000000000..3b4f4b564 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withResendEmailConfirmation } from 'coral-framework/hocs'; +import { compose } from 'recompose'; +import ResendEmailConfirmaton from '../components/ResendEmailConfirmation'; +import { connect } from 'plugin-api/beta/client/hocs'; +import { bindActionCreators } from 'redux'; +import * as views from '../enums/views'; +import { setView } from '../actions'; + +class ResendEmailConfirmatonContainer extends Component { + handleSubmit = () => { + this.props.resendEmailConfirmation(this.props.email); + }; + + componentWillReceiveProps(nextProps) { + if (nextProps.success) { + setTimeout(() => { + // allow success UI to be shown for a second, and then close the modal + this.props.setView(views.SIGN_IN); + }, 2500); + } + } + + render() { + return ( + + ); + } +} + +ResendEmailConfirmatonContainer.propTypes = { + success: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + resendEmailConfirmation: PropTypes.func.isRequired, + errorMessage: PropTypes.string.isRequired, + setView: PropTypes.func.isRequired, + email: PropTypes.string.isRequired, +}; + +const mapStateToProps = ({ talkPluginAuth: state }) => ({ + email: state.email, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + setView, + }, + dispatch + ); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withResendEmailConfirmation +)(ResendEmailConfirmatonContainer); diff --git a/plugins/talk-plugin-auth/client/login/containers/SignIn.js b/plugins/talk-plugin-auth/client/login/containers/SignIn.js index 7247a4ffe..3b84999fe 100644 --- a/plugins/talk-plugin-auth/client/login/containers/SignIn.js +++ b/plugins/talk-plugin-auth/client/login/containers/SignIn.js @@ -34,7 +34,9 @@ class SignInContainer extends Component { }; componentWillReceiveProps(nextProps) { - if (nextProps.success) { + if (nextProps.requireEmailConfirmation) { + this.props.setView(views.RESEND_EMAIL_CONFIRMATION); + } else if (nextProps.success) { window.close(); } } @@ -47,8 +49,8 @@ class SignInContainer extends Component { onPasswordChange={this.props.setPassword} onForgotPasswordLink={this.handleForgotPasswordLink} onSignUpLink={this.handleSignUpLink} - email={this.state.email} - password={this.state.password} + email={this.props.email} + password={this.props.password} errorMessage={this.props.errorMessage} onRecaptchaVerify={this.handleRecaptchaVerify} requireRecaptcha={this.props.requireRecaptcha} @@ -62,6 +64,7 @@ SignInContainer.propTypes = { signIn: PropTypes.func.isRequired, errorMessage: PropTypes.string.isRequired, requireRecaptcha: PropTypes.bool.isRequired, + requireEmailConfirmation: PropTypes.bool.isRequired, loading: PropTypes.bool.isRequired, success: PropTypes.bool.isRequired, setView: PropTypes.func.isRequired, diff --git a/plugins/talk-plugin-auth/client/login/containers/SignUp.js b/plugins/talk-plugin-auth/client/login/containers/SignUp.js new file mode 100644 index 000000000..5eb5535d0 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/containers/SignUp.js @@ -0,0 +1,126 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withSignUp } from 'coral-framework/hocs'; +import { compose } from 'recompose'; +import SignUp from '../components/SignUp'; +import { connect } from 'plugin-api/beta/client/hocs'; +import { bindActionCreators } from 'redux'; +import * as views from '../enums/views'; +import { setView, setEmail, setPassword } from '../actions'; +import t from 'coral-framework/services/i18n'; + +class SignUpContainer extends Component { + state = { + username: '', + passwordRepeat: '', + usernameError: '', + emailError: '', + passwordError: '', + passwordRepeatError: '', + }; + + validate = data => { + let valid = true; + const changes = {}; + Object.keys(data).forEach(name => { + const error = this.props.validate(name, data[name]); + if (error) { + valid = false; + } + changes[`${name}Error`] = error; + }); + + if (data.password !== data.passwordRepeat) { + changes['passwordRepeatError'] = t('sign_in.passwords_dont_match'); + valid = false; + } + + this.setState(changes); + return valid; + }; + + handleSubmit = () => { + const data = { + username: this.state.username, + email: this.props.email, + password: this.props.password, + passwordRepeat: this.state.passwordRepeat, + }; + + if (this.validate(data)) { + this.props.signUp(data); + } + }; + + setUsername = username => this.setState({ username }); + setPasswordRepeat = passwordRepeat => this.setState({ passwordRepeat }); + + handleForgotPasswordLink = () => { + this.props.setView(views.FORGOT_PASSWORD); + }; + + handleSignInLink = () => { + this.props.setView(views.SIGN_IN); + }; + + render() { + return ( + + ); + } +} + +SignUpContainer.propTypes = { + setView: PropTypes.func.isRequired, + email: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + setEmail: PropTypes.func.isRequired, + setPassword: PropTypes.func.isRequired, + signUp: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, + errorMessage: PropTypes.string.isRequired, + requireEmailConfirmation: PropTypes.bool.isRequired, + success: PropTypes.bool.isRequired, + validate: PropTypes.func.isRequired, +}; + +const mapStateToProps = ({ talkPluginAuth: state }) => ({ + email: state.email, + password: state.password, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + setView, + setEmail, + setPassword, + }, + dispatch + ); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withSignUp +)(SignUpContainer); diff --git a/plugins/talk-plugin-auth/client/login/enums/views.js b/plugins/talk-plugin-auth/client/login/enums/views.js index 4f92f4439..5c6732755 100644 --- a/plugins/talk-plugin-auth/client/login/enums/views.js +++ b/plugins/talk-plugin-auth/client/login/enums/views.js @@ -1,4 +1,4 @@ export const SIGN_IN = 'SIGN_IN'; export const FORGOT_PASSWORD = 'FORGOT_PASSWORD'; export const SIGN_UP = 'SIGN_UP'; -export const VERIFY_EMAIL = 'VERIFY_EMAIL'; +export const RESEND_EMAIL_CONFIRMATION = 'RESEND_EMAIL_CONFIRMATION';