diff --git a/client/coral-admin/src/components/Drawer.js b/client/coral-admin/src/components/Drawer.js index 60973e8c2..29365bc6d 100644 --- a/client/coral-admin/src/components/Drawer.js +++ b/client/coral-admin/src/components/Drawer.js @@ -7,12 +7,12 @@ import t from 'coral-framework/services/i18n'; import { can } from 'coral-framework/services/perms'; import cn from 'classnames'; -const CoralDrawer = ({ handleLogout, auth = {} }) => ( +const CoralDrawer = ({ handleLogout, currentUser }) => ( - {auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? ( + {currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
- {can(auth.user, 'MODERATE_COMMENTS') && ( + {can(currentUser, 'MODERATE_COMMENTS') && ( ( > {t('configure.community')} - {can(auth.user, 'UPDATE_CONFIG') && ( + {can(currentUser, 'UPDATE_CONFIG') && ( ( CoralDrawer.propTypes = { handleLogout: PropTypes.func.isRequired, restricted: PropTypes.bool, // hide app elements from a logged out user - auth: PropTypes.object, + currentUser: PropTypes.object, }; export default CoralDrawer; diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js index 6b2274d27..731c9b228 100644 --- a/client/coral-admin/src/components/Header.js +++ b/client/coral-admin/src/components/Header.js @@ -13,7 +13,7 @@ import CommunityIndicator from '../routes/Community/containers/Indicator'; const CoralHeader = ({ handleLogout, showShortcuts = () => {}, - auth, + currentUser, root, data, }) => { @@ -22,9 +22,9 @@ const CoralHeader = ({
- {auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? ( + {currentUser && can(currentUser, 'ACCESS_ADMIN') ? ( - {can(auth.user, 'MODERATE_COMMENTS') && ( + {can(currentUser, 'MODERATE_COMMENTS') && ( - {can(auth.user, 'UPDATE_CONFIG') && ( + {can(currentUser, 'UPDATE_CONFIG') && ( {}, toggleShortcutModal = () => {}, restricted = false, - auth, + currentUser, }) => (
+ -
{children}
); Layout.propTypes = { children: PropTypes.node, - auth: PropTypes.object, + currentUser: PropTypes.object, handleLogout: PropTypes.func, toggleShortcutModal: PropTypes.func, restricted: PropTypes.bool, // hide elements from a user that's logged out diff --git a/client/coral-admin/src/components/Login.css b/client/coral-admin/src/components/Login.css new file mode 100644 index 000000000..9977f858a --- /dev/null +++ b/client/coral-admin/src/components/Login.css @@ -0,0 +1,37 @@ +.layout { + max-width: 800px; + margin: 0 auto; +} + +.loginLayout { + max-width: 400px; + margin: 0 auto; +} + +.loginHeader, .loginCTA, .forgotPasswordCTA, .passwordRequestSuccess { + text-align: center; + font-size: 16px; +} + +.forgotPasswordLink, .signInLink { + color: blue; + font-weight: normal; + text-decoration: none; +} + +.forgotPasswordLink:hover, .signInLink:hover { + text-decoration: underline; +} + +.layout h1 { + font-size: 40px; +} + +.loginHeader { + font-size: 30px; +} + +.passwordRequestSuccess { + cursor: pointer; + padding: 8px 14px; +} diff --git a/client/coral-admin/src/components/Login.js b/client/coral-admin/src/components/Login.js new file mode 100644 index 000000000..66ab81e78 --- /dev/null +++ b/client/coral-admin/src/components/Login.js @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Layout from 'coral-admin/src/components/Layout'; +import styles from './Login.css'; +import { Button, TextField, Alert } from 'coral-ui'; +import cn from 'classnames'; + +class AdminLogin extends React.Component { + constructor(props) { + super(props); + } + + handleForgotPassword = e => { + e.preventDefault(); + this.props.onForgotPassword(); + }; + handleEmailChange = e => this.props.onEmailChange(e.target.value); + handlePasswordChange = e => this.props.onPasswordChange(e.target.value); + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + render() { + const { email, password, errorMessage } = this.props; + return ( + +
+

Team sign in

+

+ Sign in to interact with your community. +

+
+ {errorMessage && {errorMessage}} + + +
+ +

+ Forgot your password?{' '} + + Request a new one. + +

+ +
+ + ); + } +} + +AdminLogin.propTypes = { + email: PropTypes.string, + password: PropTypes.string, + onEmailChange: PropTypes.func, + onPasswordChange: PropTypes.func, + onForgotPassword: PropTypes.func, + onSubmit: PropTypes.func, + errorMessage: PropTypes.string, + requireRecaptcha: PropTypes.string, +}; + +export default AdminLogin; diff --git a/client/coral-admin/src/components/NotFound.css b/client/coral-admin/src/components/NotFound.css index 9977f858a..6743a144d 100644 --- a/client/coral-admin/src/components/NotFound.css +++ b/client/coral-admin/src/components/NotFound.css @@ -3,35 +3,3 @@ margin: 0 auto; } -.loginLayout { - max-width: 400px; - margin: 0 auto; -} - -.loginHeader, .loginCTA, .forgotPasswordCTA, .passwordRequestSuccess { - text-align: center; - font-size: 16px; -} - -.forgotPasswordLink, .signInLink { - color: blue; - font-weight: normal; - text-decoration: none; -} - -.forgotPasswordLink:hover, .signInLink:hover { - text-decoration: underline; -} - -.layout h1 { - font-size: 40px; -} - -.loginHeader { - font-size: 30px; -} - -.passwordRequestSuccess { - cursor: pointer; - padding: 8px 14px; -} diff --git a/client/coral-admin/src/containers/Layout.js b/client/coral-admin/src/containers/Layout.js index c06e0739b..ea98000b8 100644 --- a/client/coral-admin/src/containers/Layout.js +++ b/client/coral-admin/src/containers/Layout.js @@ -3,85 +3,64 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import Layout from '../components/Layout'; import { fetchConfig } from '../actions/config'; -import AdminLogin from '../components/AdminLogin'; +import Login from '../containers/Login'; import { FullLoading } from '../components/FullLoading'; import BanUserDialog from './BanUserDialog'; import SuspendUserDialog from './SuspendUserDialog'; import { toggleModal as toggleShortcutModal } from '../actions/moderation'; -import { - checkLogin, - handleLogin, - requestPasswordReset, - logout, -} from '../actions/auth'; +import { logout } from 'coral-framework/actions/auth'; import { can } from 'coral-framework/services/perms'; import UserDetail from 'coral-admin/src/containers/UserDetail'; import PropTypes from 'prop-types'; class LayoutContainer extends React.Component { componentWillMount() { - const { checkLogin, fetchConfig } = this.props; + const { fetchConfig } = this.props; - checkLogin(); fetchConfig(); } render() { const { - user, - loggedIn, - loadingUser, - loginError, - loginMaxExceeded, - passwordRequestSuccess, - } = this.props.auth; - - const { + currentUser, + checkedInitialLogin, children, logout, toggleShortcutModal, - TALK_RECAPTCHA_PUBLIC, } = this.props; - if (loadingUser) { + if (!checkedInitialLogin) { return ; } - if (!loggedIn) { - return ( - - ); + if (!currentUser) { + return ; } - if (can(user, 'ACCESS_ADMIN') && loggedIn) { - return ( - - - - - {children} - - ); - } else if (loggedIn) { - return ( - -

- This page is for team use only. Please contact an administrator if - you want to join this team. -

-
- ); + if (currentUser) { + if (can(currentUser, 'ACCESS_ADMIN')) { + return ( + + + + + {children} + + ); + } else { + return ( + +

+ This page is for team use only. Please contact an administrator if + you want to join this team. +

+
+ ); + } } return ; } @@ -89,29 +68,23 @@ class LayoutContainer extends React.Component { LayoutContainer.propTypes = { children: PropTypes.node, - requestPasswordReset: PropTypes.func, - handleLogin: PropTypes.func, - auth: PropTypes.object, - handleLogout: PropTypes.func, + currentUser: PropTypes.object, + checkedInitialLogin: PropTypes.bool, logout: PropTypes.func, toggleShortcutModal: PropTypes.func, TALK_RECAPTCHA_PUBLIC: PropTypes.string, - checkLogin: PropTypes.func, fetchConfig: PropTypes.func, }; const mapStateToProps = state => ({ - auth: state.auth, - TALK_RECAPTCHA_PUBLIC: state.config.data.TALK_RECAPTCHA_PUBLIC, + currentUser: state.authCore.user, + checkedInitialLogin: state.authCore.checkedInitialLogin, }); const mapDispatchToProps = dispatch => bindActionCreators( { - checkLogin, fetchConfig, - handleLogin, - requestPasswordReset, toggleShortcutModal, logout, }, diff --git a/client/coral-admin/src/containers/Login.js b/client/coral-admin/src/containers/Login.js new file mode 100644 index 000000000..3d32a2af2 --- /dev/null +++ b/client/coral-admin/src/containers/Login.js @@ -0,0 +1,44 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withLogin } from 'coral-framework/hocs'; +import { compose } from 'recompose'; +import Login from '../components/Login'; + +class LoginContainer extends Component { + state = { + email: '', + password: '', + }; + + handleSubmit = () => { + this.props.login(this.state.email, this.state.password); + }; + + handleEmailChange = email => { + this.setState({ email }); + }; + + handlePasswordChange = password => { + this.setState({ password }); + }; + + render() { + return ( + + ); + } +} + +LoginContainer.propTypes = { + login: PropTypes.func, + errorMessage: PropTypes.string, +}; + +export default compose(withLogin)(LoginContainer); diff --git a/client/coral-admin/src/routes/Configure/containers/Configure.js b/client/coral-admin/src/routes/Configure/containers/Configure.js index ce33fa1f4..ee39c584a 100644 --- a/client/coral-admin/src/routes/Configure/containers/Configure.js +++ b/client/coral-admin/src/routes/Configure/containers/Configure.js @@ -71,7 +71,7 @@ const withConfigureQuery = withQuery( ); const mapStateToProps = state => ({ - auth: state.auth, + auth: state.authCore, pending: state.configure.pending, canSave: state.configure.canSave, activeSection: state.configure.activeSection, diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index c0e2e60a6..01fd82c8a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -515,7 +515,7 @@ const withModQueueQuery = withQuery( const mapStateToProps = state => ({ moderation: state.moderation, - auth: state.auth, + auth: state.authCore, }); const mapDispatchToProps = dispatch => ({ diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js new file mode 100644 index 000000000..46d8c3835 --- /dev/null +++ b/client/coral-framework/actions/auth.js @@ -0,0 +1,97 @@ +import * as actions from '../constants/auth'; +import jwtDecode from 'jwt-decode'; + +function cleanAuthData(storage) { + storage.removeItem('token'); + storage.removeItem('exp'); +} + +/** + * Check Login + */ +export const checkLogin = () => ( + dispatch, + _, + { rest, client, pym, storage } +) => { + dispatch(checkLoginRequest()); + rest('/auth') + .then(result => { + if (!result.user) { + if (storage) { + cleanAuthData(storage); + } + dispatch(checkLoginSuccess(null)); + return; + } + + // Reset the websocket. + client.resetWebsocket(); + + dispatch(checkLoginSuccess(result.user)); + pym.sendMessage('coral-auth-changed', JSON.stringify(result.user)); + }) + .catch(error => { + if (error.status && error.status === 401 && storage) { + // Unauthorized. + cleanAuthData(storage); + } else { + console.error(error); + } + dispatch(checkLoginFailure(error)); + }); +}; + +const checkLoginRequest = () => ({ type: actions.CHECK_LOGIN_REQUEST }); + +const checkLoginFailure = error => ({ + type: actions.CHECK_LOGIN_FAILURE, + error, +}); + +const checkLoginSuccess = user => ({ + type: actions.CHECK_LOGIN_SUCCESS, + user, +}); + +/** + * Login + */ +export const handleSuccessfulLogin = (user, token) => ( + dispatch, + _, + { client, storage } +) => { + if (storage) { + storage.setItem('exp', jwtDecode(token).exp); + storage.setItem('token', token); + } + + client.resetWebsocket(); + + dispatch({ + type: actions.HANDLE_SUCCESSFUL_LOGIN, + user, + }); +}; + +/** + * Logout + */ +export const logout = () => async ( + dispatch, + _, + { rest, client, pym, storage } +) => { + await rest('/auth', { method: 'DELETE' }); + + if (storage) { + cleanAuthData(storage); + } + + // Reset the websocket. + client.resetWebsocket(); + + dispatch({ type: actions.LOGOUT }); + pym.sendMessage('coral-auth-changed'); +}; diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js new file mode 100644 index 000000000..655c29260 --- /dev/null +++ b/client/coral-framework/constants/auth.js @@ -0,0 +1,8 @@ +const prefix = `TALK_FRAMEWORK`; + +export const CHECK_LOGIN_REQUEST = `${prefix}_CHECK_LOGIN_REQUEST`; +export const CHECK_LOGIN_SUCCESS = `${prefix}_CHECK_LOGIN_SUCCESS`; +export const CHECK_LOGIN_FAILURE = `${prefix}_CHECK_LOGIN_FAILURE`; + +export const LOGOUT = `${prefix}_LOGOUT`; +export const HANDLE_SUCCESSFUL_LOGIN = `${prefix}_HANDLE_SUCCESSFUL_LOGIN`; diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 174f25a63..c871306ae 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -6,3 +6,4 @@ export { default as withEmit } from './withEmit'; export { default as excludeIf } from './excludeIf'; export { default as connect } from './connect'; export { default as withMergedSettings } from './withMergedSettings'; +export { default as withLogin } from './withLogin'; diff --git a/client/coral-framework/hocs/withLogin.js b/client/coral-framework/hocs/withLogin.js new file mode 100644 index 000000000..56586b7af --- /dev/null +++ b/client/coral-framework/hocs/withLogin.js @@ -0,0 +1,75 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; +import { handleSuccessfulLogin } from '../actions/auth'; +import { translateError } from '../utils'; +import { t } from '../services/i18n'; + +/** + * WithLogin provides properties `login`, `loading` and `errorMessage`, `requireRecaptcha`. + */ +export default hoistStatics(WrappedComponent => { + class WithLogin extends React.Component { + static contextTypes = { + store: PropTypes.object, + rest: PropTypes.func, + }; + + state = { + error: null, + loading: false, + }; + + login = (email, password, recaptchaResponse) => { + const { store, rest } = this.context; + const params = { + method: 'POST', + body: { + email, + password, + }, + }; + + if (recaptchaResponse) { + params.headers = { + 'X-Recaptcha-Response': recaptchaResponse, + }; + } + + rest('/auth/local', params) + .then(({ user, token }) => { + this.setState({ loading: false, error: null }); + store.dispatch(handleSuccessfulLogin(user, token)); + }) + .catch(error => { + if (!error.status || error.status !== 401) { + console.error(error); + } + this.setState({ loading: false, error }); + }); + }; + + getErrorMessage() { + if (!this.state.error) { + return ''; + } + return this.state.error.translation_key === 'NOT_AUTHORIZED' + ? t('error.email_password') + : translateError(this.state.error); + } + + render() { + return ( + + ); + } + } + + return WithLogin; +}); diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js new file mode 100644 index 000000000..04e75bbb4 --- /dev/null +++ b/client/coral-framework/reducers/auth.js @@ -0,0 +1,42 @@ +import * as actions from '../constants/auth'; + +const initialState = { + checkedInitialLogin: false, + initialLoginError: null, + user: null, +}; + +const purge = user => { + const {settings, ...userData} = user; // eslint-disable-line + return userData; +}; + +export default function auth(state = initialState, action) { + switch (action.type) { + case actions.CHECK_LOGIN_FAILURE: + return { + ...state, + initialLoginError: action.error, + checkedInitialLogin: true, + user: null, + }; + case actions.CHECK_LOGIN_SUCCESS: + return { + ...state, + checkedInitialLogin: true, + user: action.user ? purge(action.user) : null, + }; + case actions.HANDLE_SUCCESSFUL_LOGIN: + return { + ...state, + user: action.user ? purge(action.user) : null, + }; + case actions.LOGOUT: + return { + ...state, + user: null, + }; + default: + return state; + } +} diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js index 66da3ff29..18bacdd7f 100644 --- a/client/coral-framework/services/bootstrap.js +++ b/client/coral-framework/services/bootstrap.js @@ -22,6 +22,8 @@ import { import { createHistory } from 'coral-framework/services/history'; import { createIntrospection } from 'coral-framework/services/introspection'; import introspectionData from 'coral-framework/graphql/introspection.json'; +import auth from '../reducers/auth'; +import { checkLogin } from '../actions/auth'; /** * getAuthToken returns the active auth token or null @@ -143,6 +145,7 @@ export async function createContext({ // Create our redux store. const finalReducers = { + authCore: auth, ...reducers, ...plugins.getReducers(), }; @@ -162,6 +165,8 @@ export async function createContext({ [client.middleware(), apolloErrorReporter, createReduxEmitter(eventEmitter)] ); + store.dispatch(checkLogin()); + // Run pre initialization. if (preInit) { await preInit(context); diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index d53b9b496..66792b9b3 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -252,3 +252,12 @@ export function mapLeaves(o, mapper) { return mapper(val); }); } + +export function translateError(error) { + if (error.translation_key) { + return t(`error.${error.translation_key}`); + } else if (error.networkError) { + return t('error.network_error'); + } + return error.toString(); +}