diff --git a/client/package.json b/client/package.json index 52350b4ae..f5dd2ccb1 100644 --- a/client/package.json +++ b/client/package.json @@ -62,6 +62,7 @@ "postcss-safe-parser": "4.0.1", "react": "^16.12.0", "react-app-polyfill": "^1.0.6", + "react-body-classname": "^1.3.1", "react-dev-utils": "^10.2.0", "react-dom": "^16.12.0", "react-grid-system": "^6.2.3", diff --git a/client/src/components/App.js b/client/src/components/App.js index 8dd729cd8..0453ac65e 100644 --- a/client/src/components/App.js +++ b/client/src/components/App.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { IntlProvider } from 'react-intl'; import { connect } from 'react-redux'; -import { Router, Switch } from 'react-router'; +import { Router, Switch, Redirect } from 'react-router'; import { createBrowserHistory } from 'history'; import PrivateRoute from 'components/PrivateRoute'; import Authentication from 'components/Authentication'; diff --git a/client/src/components/Authentication.js b/client/src/components/Authentication.js index c1cdab835..cc2871888 100644 --- a/client/src/components/Authentication.js +++ b/client/src/components/Authentication.js @@ -1,29 +1,38 @@ import React from 'react'; -import { Redirect, Route, Switch } from 'react-router-dom'; +import { Redirect, Route, Switch, Link } from 'react-router-dom'; +import BodyClassName from 'react-body-classname'; import authenticationRoutes from 'routes/authentication'; export default function({ isAuthenticated =false, ...rest }) { const to = {pathname: '/dashboard/homepage'}; return ( - - { (isAuthenticated) ? - () : ( - -
-
- { authenticationRoutes.map((route, index) => ( - - ))} + + + { (isAuthenticated) ? + () : ( + +
+ + ← Go to bigcapital.com + + +
+ { authenticationRoutes.map((route, index) => ( + + ))} +
-
- ) - } - + ) + } + + ); } \ No newline at end of file diff --git a/client/src/connectors/Authentication.connect.js b/client/src/connectors/Authentication.connect.js new file mode 100644 index 000000000..5cbb943b4 --- /dev/null +++ b/client/src/connectors/Authentication.connect.js @@ -0,0 +1,24 @@ +import { + login, + resetPassword, + sendResetPassword, + inviteAccept, + register, + inviteMetaByToken, +} from 'store/authentication/authentication.actions'; +import { connect } from 'react-redux'; + +const mapStateToProps = (state) => ({ + +}); + +const mapDispatchToProps = (dispatch) => ({ + requestLogin: (form) => dispatch(login({ form })), + requestRegister: (form) => dispatch(register({ form })), + requestSendResetPassword: (email) => dispatch(sendResetPassword({ email })), + requestResetPassword: (form, token) => dispatch(resetPassword({ form, token })), + requestInviteAccept: (form, token) => dispatch(inviteAccept({ form, token })), + requestInviteMetaByToken: (token) => dispatch(inviteMetaByToken({ token })), +}); + +export default connect(mapStateToProps, mapDispatchToProps); \ No newline at end of file diff --git a/client/src/connectors/InviteForm.connect.js b/client/src/connectors/InviteForm.connect.js deleted file mode 100644 index 83c5b7699..000000000 --- a/client/src/connectors/InviteForm.connect.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { submitInvite, submitSendInvite } from 'store/Invite/invite.action'; - -export const mapStateToProps = (state, props) => { - return {}; -}; - -export const mapDispatchToProps = (dispatch) => ({ - requestSubmitInvite: (form, token) => dispatch(submitInvite({ form, token })), - requestSendInvite: (form) => dispatch(submitSendInvite({ form })), -}); - -export default connect(mapStateToProps, mapDispatchToProps); diff --git a/client/src/connectors/ResetPassword.connect.js b/client/src/connectors/ResetPassword.connect.js deleted file mode 100644 index 5dc209556..000000000 --- a/client/src/connectors/ResetPassword.connect.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { submitResetPassword } from 'store/resetPassword/resetPassword.action'; - -export const mapStateToProps = (state, props) => { - return {}; -}; - -export const mapDispatchToProps = (dispatch) => ({ - requestResetPassword: (password) => - dispatch(submitResetPassword({password})), -}); - -export default connect(mapStateToProps, mapDispatchToProps); diff --git a/client/src/containers/Authentication/AuthCopyright.js b/client/src/containers/Authentication/AuthCopyright.js new file mode 100644 index 000000000..2d87aa8cc --- /dev/null +++ b/client/src/containers/Authentication/AuthCopyright.js @@ -0,0 +1,16 @@ +import React from 'react'; +import Icon from 'components/Icon'; + +function AuthCopyright() { + return ( + + ); +} + +export default AuthCopyright; diff --git a/client/src/containers/Authentication/AuthInsider.js b/client/src/containers/Authentication/AuthInsider.js new file mode 100644 index 000000000..7484693c5 --- /dev/null +++ b/client/src/containers/Authentication/AuthInsider.js @@ -0,0 +1,29 @@ +import React from 'react'; +import Icon from 'components/Icon'; +import AuthCopyright from './AuthCopyright'; + +export default function AuthInsider({ + logo = true, + copyright = true, + children, +}) { + + return ( +
+
+ +
+ +
+ { children } +
+ + +
+ ) +} \ No newline at end of file diff --git a/client/src/containers/Authentication/InviteAccept.js b/client/src/containers/Authentication/InviteAccept.js index aec099b18..8da20473b 100644 --- a/client/src/containers/Authentication/InviteAccept.js +++ b/client/src/containers/Authentication/InviteAccept.js @@ -1,174 +1,227 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import * as Yup from 'yup'; import { useFormik } from 'formik'; import { useIntl } from 'react-intl'; import ErrorMessage from 'components/ErrorMessage'; import AppToaster from 'components/AppToaster'; import { compose } from 'utils'; -import InviteFormConnect from 'connectors/InviteForm.connect'; +import AuthenticationConnect from 'connectors/Authentication.connect'; import { useParams } from 'react-router-dom'; import { Button, InputGroup, Intent, FormGroup, - HTMLSelect, + Position, + Spinner, } from '@blueprintjs/core'; +import Icon from 'components/Icon'; +import { Row, Col } from 'react-grid-system'; +import AuthInsider from 'containers/Authentication/AuthInsider'; +import { Link, useHistory } from 'react-router-dom'; +import useAsync from 'hooks/async'; -function Invite({ requestSubmitInvite }) { +function Invite({ + requestInviteAccept, + requestInviteMetaByToken, +}) { const intl = useIntl(); - let params = useParams('accept/:token'); - - const { token } = params; - - const phoneRegExp = /^((\\+[1-9]{1,4}[ \\-]*)|(\\([0-9]{2,3}\\)[ \\-]*)|([0-9]{2,4})[ \\-]*)*?[0-9]{3,4}?[ \\-]*[0-9]{3,4}?$/; - - const language = useMemo(() => [ - { value: null, label: 'Select Country' }, - { value: 'Arabic', label: 'Arabic' }, - { value: 'English', label: 'English' }, - ], []); + const { token } = useParams(); + const history = useHistory(); + const [shown, setShown] = useState(false); + const passwordRevealer = useCallback(() => { setShown(!shown); }, [shown]); const ValidationSchema = Yup.object().shape({ first_name: Yup.string().required(), last_name: Yup.string().required(), - email: Yup.string().email().required(), - phone_number: Yup.string().matches(phoneRegExp).required(), - language: Yup.string().required(), + phone_number: Yup.string().matches().required(), password: Yup.string() .min(4, 'Password has to be longer than 4 characters!') .required('Password is required!'), }); - const initialValues = useMemo( - () => ({ - first_name: '', - last_name: '', - email: '', - phone_number: '', - language: '', - password: '', - }), - [] - ); + const inviteMeta = useAsync(() => { + return requestInviteMetaByToken(token); + }); + + const inviteErrors = inviteMeta.error || []; + const inviteValue = { + organization_name: '', + invited_email: '', + ...inviteMeta.value ? + inviteMeta.value.data.data : {}, + }; + + if (inviteErrors.find(e => e.type === 'INVITE.TOKEN.NOT.FOUND')) { + AppToaster.show({ + message: 'An unexpected error occurred', + intent: Intent.DANGER, + position: Position.BOTTOM, + }); + history.push('/auth/login'); + } + + const initialValues = useMemo(() => ({ + first_name: '', + last_name: '', + phone_number: '', + password: '', + }), []); + const { - handleSubmit, - errors, values, touched, + errors, + handleSubmit, getFieldProps, + isSubmitting, } = useFormik({ enableReinitialize: true, validationSchema: ValidationSchema, initialValues: { ...initialValues, }, - onSubmit: (values, { setSubmitting }) => { - requestSubmitInvite(values, token).then((response) => { - AppToaster.show({ - message: 'success', + onSubmit: (values, { setSubmitting, setErrors }) => { + requestInviteAccept(values, token) + .then((response) => { + AppToaster.show({ + message: `Congrats! Your account has been created and invited to + ${inviteValue.organization_name} organization successfully.`, + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }) + .catch((errors) => { + if (errors.find((e) => e.type === 'INVITE.TOKEN.NOT.FOUND')) { + AppToaster.show({ + message: 'An unexpected error occurred', + intent: Intent.DANGER, + position: Position.BOTTOM, + }); + history.push('/auth/login'); + } + if (errors.find(e => e.type === 'PHONE_MUMNER.ALREADY.EXISTS')){ + setErrors({ + phone_number: 'This phone number is used in another account.' + }); + } + setSubmitting(false); }); - setSubmitting(false); - }) - .catch((error) => { - setSubmitting(false); - }); }, }); - const requiredSpan = useMemo(() => *, []); + + const passwordRevealerTmp = useMemo(() => ( + passwordRevealer()}> + {(shown) ? ( + <> Hide + ) : ( + <> Show + )} + ), [shown, passwordRevealer]); return ( -
-
- } - > - - - - } - > - - - - } - > - - - - } - > - - - - } - > - - - - } - > - - - -
+ ); } -export default compose(InviteFormConnect)(Invite); +export default compose(AuthenticationConnect)(Invite); diff --git a/client/src/containers/Authentication/Login.js b/client/src/containers/Authentication/Login.js index 64b6ff886..659bcc05e 100644 --- a/client/src/containers/Authentication/Login.js +++ b/client/src/containers/Authentication/Login.js @@ -1,135 +1,169 @@ -import React, { useEffect } from "react"; -import {Link, useHistory} from 'react-router-dom'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, useHistory } from 'react-router-dom'; import * as Yup from 'yup'; -import {useFormik} from 'formik'; -import {connect} from 'react-redux'; -import {useIntl} from 'react-intl'; +import { useFormik } from 'formik'; +import { connect } from 'react-redux'; +import { useIntl } from 'react-intl'; import { Button, InputGroup, Intent, FormGroup, -} from "@blueprintjs/core"; -import login from 'store/authentication/authentication.actions'; -import {hasErrorType} from 'store/authentication/authentication.reducer'; + Checkbox, + Position, +} from '@blueprintjs/core'; import AuthenticationToaster from 'components/AppToaster'; -import t from 'store/types'; +import ErrorMessage from 'components/ErrorMessage'; +import AuthInsider from 'containers/Authentication/AuthInsider'; +import Icon from 'components/Icon'; +import AuthenticationConnect from 'connectors/Authentication.connect'; +import { compose } from 'utils'; const ERRORS_TYPES = { INVALID_DETAILS: 'INVALID_DETAILS', USER_INACTIVE: 'USER_INACTIVE', }; function Login({ - login, + requestLogin, }) { const intl = useIntl(); const history = useHistory(); + const [shown, setShown] = useState(false); + const passwordRevealer = () => { setShown(!shown); }; // Validation schema. - const loginValidationSchema = Yup.object().shape({ - crediential: Yup - .string() - .required(intl.formatMessage({'id': 'required'})) - .email(intl.formatMessage({id: 'invalid_email_or_phone_numner'})), - password: Yup - .string() - .required(intl.formatMessage({id: 'required'})) + const loginValidationSchema = Yup.object().shape({ + crediential: Yup.string() + .required(intl.formatMessage({ id: 'required' })) + .email(intl.formatMessage({ id: 'invalid_email_or_phone_numner' })), + password: Yup.string() + .required(intl.formatMessage({ id: 'required' })) .min(4), }); // Formik validation schema and submit handler. - const formik = useFormik({ + const { + values, + touched, + errors, + handleSubmit, + getFieldProps, + isSubmitting, + } = useFormik({ initialValues: { crediential: '', password: '', }, validationSchema: loginValidationSchema, - onSubmit: async (values) => { - login({ + onSubmit: (values, { setSubmitting }) => { + requestLogin({ crediential: values.crediential, password: values.password, }).then(() => { history.go('/dashboard/homepage'); + setSubmitting(false); }).catch((errors) => { const toastBuilders = []; if (errors.find((e) => e.type === ERRORS_TYPES.INVALID_DETAILS)) { toastBuilders.push({ - message: intl.formatMessage({ id: 'invalid_email_or_phone_numner' }), - intent: Intent.WARNING, + message: `The email and password you entered did not match our records. + Please double-check and try again.`, + intent: Intent.DANGER, + position: Position.BOTTOM, }); } if (errors.find((e) => e.type === ERRORS_TYPES.USER_INACTIVE)) { toastBuilders.push({ message: intl.formatMessage({ id: 'the_user_has_been_suspended_from_admin' }), - intent: Intent.WARNING, + intent: Intent.DANGER, }); } toastBuilders.forEach(builder => { AuthenticationToaster.show(builder); }); + setSubmitting(false); }); }, }); + const passwordRevealerTmp = useMemo(() => ( + passwordRevealer()}> + {(shown) ? ( + <> Hide + ) : ( + <> Show + )} + ), [shown, passwordRevealer]); + return ( -
-
- + +
+
+

Log in

+ Need a Bigcapital account ? + Create an account +
- - + + } + className={'form-group--crediential'} + > + + + + } + className={'form-group--password has-password-revealer'} + > + + + +
+ + Keep me logged in + +
- - - - - - - - -
- - {intl.formatMessage({'id': 'reset_password '})} - - - - {intl.formatMessage({'id': 'register '})} - +
+ +
+ + +
-
+
); } -const mapStateToProps = (state) => ({ - hasError: (errorType) => hasErrorType(state, errorType), - errors: state.authentication.errors, -}); - -const mapDispatchToProps = (dispatch) => ({ - login: form => dispatch(login({ form })), - clearErrors: () => dispatch({ type: t.LOGIN_CLEAR_ERRORS }), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Login); \ No newline at end of file +export default compose( + AuthenticationConnect +)(Login); \ No newline at end of file diff --git a/client/src/containers/Authentication/Register.js b/client/src/containers/Authentication/Register.js index b64e8b5f1..c2809ec35 100644 --- a/client/src/containers/Authentication/Register.js +++ b/client/src/containers/Authentication/Register.js @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import * as Yup from 'yup'; import { useFormik } from 'formik'; import { useIntl } from 'react-intl'; @@ -7,21 +7,24 @@ import { InputGroup, Intent, FormGroup, - HTMLSelect, + Spinner } from '@blueprintjs/core'; -import RegisterFromConnect from 'connectors/RegisterForm.connect'; +import { Row, Col } from 'react-grid-system'; +import { Link, useHistory } from 'react-router-dom'; +import AuthenticationConnect from 'connectors/Authentication.connect'; import ErrorMessage from 'components/ErrorMessage'; import AppToaster from 'components/AppToaster'; +import AuthInsider from 'containers/Authentication/AuthInsider'; import { compose } from 'utils'; +import Icon from 'components/Icon'; function Register({ - requestSubmitRegister, + requestRegister, }) { const intl = useIntl(); - const Country = useMemo(() => [ - { value: null, label: 'Select Country' }, - { value: 'libya', label: 'Libya' }, - ], []); + const history = useHistory(); + const [shown, setShown] = useState(false); + const passwordRevealer = useCallback(() => { setShown(!shown); }, [shown]); const ValidationSchema = Yup.object().shape({ organization_name: Yup.string().required(), @@ -29,11 +32,11 @@ function Register({ last_name: Yup.string().required(), email: Yup.string().email().required(), phone_number: Yup.string() + .matches() .required(intl.formatMessage({ id: 'required' })), password: Yup.string() .min(4, 'Password has to be longer than 8 characters!') .required('Password is required!'), - country: Yup.string().required(intl.formatMessage({ id: 'required' })), }); const initialValues = useMemo(() => ({ @@ -43,29 +46,31 @@ function Register({ email: '', phone_number: '', password: '', - country: '', }), []); const { - getFieldProps, - getFieldMeta, errors, - values, touched, + values, + setFieldValue, handleSubmit, + getFieldProps, + isSubmitting, } = useFormik({ enableReinitialize: true, validationSchema: ValidationSchema, initialValues: { ...initialValues, + country: 'libya' }, onSubmit: (values, { setSubmitting }) => { - requestSubmitRegister(values) + requestRegister(values) .then((response) => { AppToaster.show({ message: 'success', }); setSubmitting(false); + history.push('/auth/login'); }) .catch((error) => { setSubmitting(false); @@ -73,122 +78,140 @@ function Register({ }, }); - const requiredSpan = useMemo(() => *, []); + const passwordRevealerTmp = useMemo(() => ( + passwordRevealer()}> + {(shown) ? ( + <> Hide + ) : ( + <> Show + )} + ), [shown, passwordRevealer]); return ( -
-
- } - > - - - - } - > - - - - } - > - - - - } - > - - - - } - > - - - - } - > - - - - } - > - - - -
+ ); } export default compose( - RegisterFromConnect, + AuthenticationConnect, )(Register); diff --git a/client/src/containers/Authentication/ResetPassword.js b/client/src/containers/Authentication/ResetPassword.js index 11d29c6e0..3ba16068c 100644 --- a/client/src/containers/Authentication/ResetPassword.js +++ b/client/src/containers/Authentication/ResetPassword.js @@ -7,17 +7,22 @@ import { InputGroup, Intent, FormGroup, - HTMLSelect, + Position, } from '@blueprintjs/core'; +import { Link, useParams, useHistory } from 'react-router-dom'; import ErrorMessage from 'components/ErrorMessage'; import AppToaster from 'components/AppToaster'; import { compose } from 'utils'; -import SendResetPasswordConnect from 'connectors/ResetPassword.connect'; +import AuthenticationConnect from 'connectors/Authentication.connect'; +import AuthInsider from 'containers/Authentication/AuthInsider'; function ResetPassword({ - requestSendResetPassword, + requestResetPassword, }) { const intl = useIntl(); + const { token } = useParams(); + const history = useHistory(); + const ValidationSchema = Yup.object().shape({ password: Yup.string() .min(4, 'Password has to be longer than 4 characters!') @@ -33,12 +38,13 @@ function ResetPassword({ }), []); const { - errors, - values, touched, - getFieldMeta, - getFieldProps, + values, + errors, handleSubmit, + setFieldValue, + getFieldProps, + isSubmitting, } = useFormik({ enableReinitialize: true, validationSchema: ValidationSchema, @@ -46,68 +52,84 @@ function ResetPassword({ ...initialValues, }, onSubmit: (values, { setSubmitting }) => { - requestSendResetPassword(values.password) + requestResetPassword(values, token) .then((response) => { AppToaster.show({ - message: 'success', + message: 'The password for your account was successfully updated.', + intent: Intent.DANGER, + position: Position.BOTTOM, }); + history.push('/auth/login'); setSubmitting(false); }) - .catch((error) => { + .catch((errors) => { + if (errors.find(e => e.type === 'TOKEN_INVALID')) { + AppToaster.show({ + message: 'An unexpected error occurred', + intent: Intent.DANGER, + position: Position.BOTTOM, + }); + history.push('/auth/login'); + } setSubmitting(false); }); }, }); - const requiredSpan = useMemo(() => *, []); - return ( -
-
- } - > - - - - } - > - - - -
+ ); } -export default compose(SendResetPasswordConnect)(ResetPassword); +export default compose( + AuthenticationConnect, +)(ResetPassword); diff --git a/client/src/containers/Authentication/SendResetPassword.js b/client/src/containers/Authentication/SendResetPassword.js index d4b78f2f0..24cfeb7e5 100644 --- a/client/src/containers/Authentication/SendResetPassword.js +++ b/client/src/containers/Authentication/SendResetPassword.js @@ -1,29 +1,114 @@ -import * as React from 'react'; -import { Link } from 'react-router-dom'; -import { Button, InputGroup } from '@blueprintjs/core'; +import React, { useMemo } from 'react'; +import * as Yup from 'yup'; +import { useFormik } from 'formik'; +import { useIntl } from 'react-intl'; +import { Link, useHistory } from 'react-router-dom'; +import { Button, InputGroup, Intent, FormGroup } from '@blueprintjs/core'; import { FormattedMessage } from 'react-intl'; +import ErrorMessage from 'components/ErrorMessage'; +import AuthenticationConnect from 'connectors/Authentication.connect'; +import { compose } from 'utils'; +import AppToaster from 'components/AppToaster'; +import AuthInsider from 'containers/Authentication/AuthInsider'; -export default function SendResetPassword() { +function SendResetPassword({ + requestSendResetPassword, +}) { + const intl = useIntl(); + const history = useHistory(); + + // Validation schema. + const ValidationSchema = Yup.object().shape({ + crediential: Yup.string('') + .required(intl.formatMessage({ id: 'required' })) + .email(intl.formatMessage({ id: 'invalid_email_or_phone_numner' })), + }); + + const initialValues = useMemo(() => ({ + crediential: '', + }), []); + + // Formik validation + const { + values, + errors, + touched, + handleSubmit, + getFieldProps, + setFieldValue, + isSubmitting, + } = useFormik({ + enableReinitialize: true, + validationSchema: ValidationSchema, + initialValues: { + ...initialValues, + }, + onSubmit: (values, { setSubmitting }) => { + requestSendResetPassword(values.crediential) + .then((response) => { + AppToaster.show({ + message: `Check your email for a link to reset your password. + If it doesn’t appear within a few minutes, check your spam folder.`, + intent: Intent.SUCCESS, + }); + history.push('/auth/login'); + setSubmitting(false); + }) + .catch((errors) => { + if (errors.find(e => e.type === 'EMAIL.NOT.REGISTERED')){ + AppToaster.show({ + message: 'We couldn\'t find your account with that email', + intent: Intent.DANGER, + }); + } + setSubmitting(false); + }); + }, + }); return ( -