Auth Refactor part 1

This commit is contained in:
Chi Vinh Le
2018-02-06 20:30:31 +01:00
parent 901c96a67a
commit f8e0556904
17 changed files with 463 additions and 111 deletions
+5 -5
View File
@@ -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 }) => (
<Drawer className={cn('talk-admin-drawer-nav', styles.drawer)}>
{auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? (
{currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
<div>
<Navigation className={styles.nav}>
{can(auth.user, 'MODERATE_COMMENTS') && (
{can(currentUser, 'MODERATE_COMMENTS') && (
<IndexLink
className={cn('talk-admin-nav-moderate', styles.navLink)}
to="/admin/moderate"
@@ -35,7 +35,7 @@ const CoralDrawer = ({ handleLogout, auth = {} }) => (
>
{t('configure.community')}
</Link>
{can(auth.user, 'UPDATE_CONFIG') && (
{can(currentUser, 'UPDATE_CONFIG') && (
<Link
className={cn('talk-admin-nav-configure', styles.navLink)}
to="/admin/configure"
@@ -55,7 +55,7 @@ const CoralDrawer = ({ handleLogout, auth = {} }) => (
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;
+5 -5
View File
@@ -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 = ({
<Header className={styles.header}>
<Logo className={styles.logo} />
<div>
{auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? (
{currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
<Navigation className={styles.nav}>
{can(auth.user, 'MODERATE_COMMENTS') && (
{can(currentUser, 'MODERATE_COMMENTS') && (
<IndexLink
id="moderateNav"
className={cn('talk-admin-nav-moderate', styles.navLink)}
@@ -54,7 +54,7 @@ const CoralHeader = ({
<CommunityIndicator root={root} data={data} />
</Link>
{can(auth.user, 'UPDATE_CONFIG') && (
{can(currentUser, 'UPDATE_CONFIG') && (
<Link
id="configureNav"
className={cn('talk-admin-nav-configure', styles.navLink)}
@@ -116,7 +116,7 @@ const CoralHeader = ({
};
CoralHeader.propTypes = {
auth: PropTypes.object,
currentUser: PropTypes.object,
showShortcuts: PropTypes.func,
handleLogout: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
+8 -4
View File
@@ -10,22 +10,26 @@ const Layout = ({
handleLogout = () => {},
toggleShortcutModal = () => {},
restricted = false,
auth,
currentUser,
}) => (
<LayoutMDL className={styles.layout} fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
auth={auth}
currentUser={currentUser}
/>
<Drawer
handleLogout={handleLogout}
restricted={restricted}
currentUser={currentUser}
/>
<Drawer handleLogout={handleLogout} restricted={restricted} auth={auth} />
<div className={styles.layout}>{children}</div>
</LayoutMDL>
);
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
@@ -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;
}
@@ -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 (
<Layout fixedDrawer restricted={true}>
<div className={cn(styles.loginLayout, 'talk-admin-login')}>
<h1 className={styles.loginHeader}>Team sign in</h1>
<p className={styles.loginCTA}>
Sign in to interact with your community.
</p>
<form
className="talk-admin-login-sign-in"
onSubmit={this.handleSubmit}
>
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
id="email"
label="Email Address"
value={email}
onChange={this.handleEmailChange}
/>
<TextField
id="password"
label="Password"
value={password}
onChange={this.handlePasswordChange}
type="password"
/>
<div style={{ height: 10 }} />
<Button
className="talk-admin-login-sign-in-button"
type="submit"
cStyle="black"
full
>
Sign In
</Button>
<p className={styles.forgotPasswordCTA}>
Forgot your password?{' '}
<a
href="#"
className={styles.forgotPasswordLink}
onClick={this.handleForgotPassword}
>
Request a new one.
</a>
</p>
</form>
</div>
</Layout>
);
}
}
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;
@@ -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;
}
+36 -63
View File
@@ -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 <FullLoading />;
}
if (!loggedIn) {
return (
<AdminLogin
loginMaxExceeded={loginMaxExceeded}
handleLogin={this.props.handleLogin}
requestPasswordReset={this.props.requestPasswordReset}
passwordRequestSuccess={passwordRequestSuccess}
recaptchaPublic={TALK_RECAPTCHA_PUBLIC}
errorMessage={loginError}
/>
);
if (!currentUser) {
return <Login />;
}
if (can(user, 'ACCESS_ADMIN') && loggedIn) {
return (
<Layout
handleLogout={logout}
toggleShortcutModal={toggleShortcutModal}
auth={this.props.auth}
>
<BanUserDialog />
<SuspendUserDialog />
<UserDetail />
{children}
</Layout>
);
} else if (loggedIn) {
return (
<Layout {...this.props}>
<p>
This page is for team use only. Please contact an administrator if
you want to join this team.
</p>
</Layout>
);
if (currentUser) {
if (can(currentUser, 'ACCESS_ADMIN')) {
return (
<Layout
handleLogout={logout}
toggleShortcutModal={toggleShortcutModal}
currentUser={this.props.currentUser}
>
<BanUserDialog />
<SuspendUserDialog />
<UserDetail />
{children}
</Layout>
);
} else {
return (
<Layout {...this.props}>
<p>
This page is for team use only. Please contact an administrator if
you want to join this team.
</p>
</Layout>
);
}
}
return <FullLoading />;
}
@@ -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,
},
@@ -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 (
<Login
onSubmit={this.handleSubmit}
onEmailChange={this.handleEmailChange}
onPasswordChange={this.handlePasswordChange}
email={this.state.email}
password={this.state.password}
errorMessage={this.props.errorMessage}
/>
);
}
}
LoginContainer.propTypes = {
login: PropTypes.func,
errorMessage: PropTypes.string,
};
export default compose(withLogin)(LoginContainer);
@@ -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,
@@ -515,7 +515,7 @@ const withModQueueQuery = withQuery(
const mapStateToProps = state => ({
moderation: state.moderation,
auth: state.auth,
auth: state.authCore,
});
const mapDispatchToProps = dispatch => ({
+97
View File
@@ -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');
};
+8
View File
@@ -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`;
+1
View File
@@ -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';
+75
View File
@@ -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 (
<WrappedComponent
{...this.props}
login={this.login}
loading={this.state.loading}
errorMessage={this.getErrorMessage()}
requireRecaptcha={false}
/>
);
}
}
return WithLogin;
});
+42
View File
@@ -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;
}
}
+5
View File
@@ -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);
+9
View File
@@ -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();
}