A set of React/Redux components to implement the Zooppa authentication layer


Keywords
zooppa, auth
License
MIT
Install
npm install @zooppa/zooppa-auth@0.20.1

Documentation

zooppa-auth Build Status codecov

A set of utilities and authentication components designed for the Zooppa platform, meant to connect to an API developed using the devise_token_auth gem.

Strongly inspired by the redux-auth library, whose code has been partially adapted for the oAuth actions.

The examples assume react-router v3. This is because we will utilize redux-auth-wrapper, which does not support the newest react-router v4 at the time of writing. The examples will be updated as soon as react-router v4 is supported by redux-auth-wrapper.

This package requires redux and the redux-thunk middleware.

Work In Progress Warning

The public interface of this project is being actively developed. It is, therefore, still evolving and subject to sudden change. Wait until version 1.0.0 if you want to enjoy a stable API.

Installing

npm install @zooppa/zooppa-auth lodash@^4.17.4 qs@^6.5.0

or

yarn add @zooppa/zooppa-auth lodash@^4.17.4 qs@^6.5.0

Using

Setting up the root reducer (importing the auth reducer):

// reducers/root.js
import { combineReducers } from 'redux';
import { authReducer as auth } from '@zooppa/zooppa-auth';

export default combineReducers({
  auth
});

Setting up the redux store (incuding the thunk middleware):

// store.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/root';

const configureStore = () =>
  createStore(
    rootReducer,
    /* preloadedState, */
    applyMiddleware(thunk)
  );

Bootstrapping your application correctly:

// index.js
import { actions } from '@zooppa/zooppa-auth';
const { configure:configureAuth, logout, validateToken } = actions;

const refreshCurrentUserByToken = store => (nextState, replace) => {
  store.dispatch(validateToken());
};

const doLogout = store => (nextState, replace) => {
  store.dispatch(logout());
  replace('/');
};

const store = configureStore();

// call this BEFORE rendering
store.dispatch(configureAuth({
  apiRoot: BASE_API_URL,
}));

ReactDOM.render(
  <Provider store={store}>
    <Router history={browserHistory}>
      <Route path="/" onEnter={refreshCurrentUserByToken(store)} component={App} >
        <IndexRoute component={Home} />
        <Route path="login" component={LoginPage} />
        <Route path="logout" onEnter={doLogout(store)} />
        <Route path="*" component={Page404} />
      </Route>
    </Router>
  </Provider>,
  document.getElementById('root'),
);

Creating your components leveraging the zooppa-auth actions:

// MyComponent.js
import React, { Component } from 'react';
import { connect } from 'react-redux';

import { actions } from '@zooppa/zooppa-auth';
const { logout } = actions;

class MyComponent extends Component {
  render() {
    const { isAuthenticated, logout } = this.props;
    const logoutButton = isAuthenticated ?
      <button onClick={logout} /> :
      null;

    return (
      <div>
        User is { isAuthenticated ? 'logged in' : 'logged out' }
        { logoutButton }
      </div>
    );
  }
}

const mapStateToProps = ({ auth }) => ({
  isAuthenticated: !!auth.session.currentUser // << current user info
});

export default connect(mapStateToProps, { logout })(MyComponent);

Authenticating with oAuth2:

// FacebookButton.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { actions } from '@zooppa/zooppa-auth';

import { BASE_FRONTEND_URL } from './config';

const { authenticateWithOAuth } = actions;

class FacebookButton extends Component {
  static propTypes = {
    authenticateWithOauth: PropTypes.function.isRequired
  }

  constructor(props) {
    super(props);
    this.authenticateWithFacebook = this.authenticateWithFacebook.bind(this);
  }

  authenticateWithFacebook() {
    this.props
      .authenticateWithOAuth({
        provider: 'facebook',
        popupLandingUrl: `${BASE_FRONTEND_URL}/oauth_landing`,
      })
      .then(() => this.context.router.replace('/'));
  }

  render() {
    return (
      <button className="facebook" onClick={this.authenticateWithFacebook}>
        Sign in via Facebook
      </button>
    );
  }
}

FacebookButton.contextTypes = {
  router: PropTypes.object.isRequired,
};

export default connect(null, { authenticateWithOAuth })(FacebookButton);

Signing up via email:

// SignupPage.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { get } from 'lodash';

class SignupPage extends Component {
  static propTypes = {
    signup: PropTypes.function.isRequired
  }

  constructor(props) {
    super(props);
    this.state = {
      email: '',
      password: '',
      password_confirmation: '',
    }
    this.handleInput = this.handleInput.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleInput(field) {
    return e => this.setState({ [field]: e.target.value });
  }

  handleSubmit(e) {
    e.preventDefault();
    this.props.signup(Object.assign(
      { confirm_success_url: 'http://example.net' },
      this.state,
    ))
    .then(() => { this.context.router.redirect('/signup_confirm') }) // mount a signup confirmation page here
    .catch(console.log);
  }

  render () {
    const { signup } = this.props;
    const errors = this.props.errors.map(error => <li>{error}</li>);

    return (
      <form onSubmit={this.handleSubmit}>
        Email: <input type="text" value={this.state.email} />
        Password: <input type="password" value={this.state.password} />
        Password confirmation: <input type="password_confirmation" value={this.state.password} />
        <button type="submit">Sign Up</button>
      </form>
      {errors}
    );
  }
}

const mapStateToProps = ({ auth }) => ({
  errors: get(auth.signup.errors, 'full_messages', []);
});

export default connect(mapStateToProps, { signup })(SignupPage);

// App.js, assuming it's mounted @ the confirm_success_url path
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { writeToLocalStorage } from '@zooppa/zooppa-auth';

class App extends Component {
  static propTypes = {
    location: PropTypes.shape({
      query: PropTypes.shape({
        account_confirmation_success: PropTypes.string,
        uid: PropTypes.string,
        client_id: PropTypes.string,
        token: PropTypes.string,
      }),
    }),
  };

  // or connect() it!
  static contextTypes = {
    store: PropTypes.object.isRequired,
  };

  componentWillMount() {
    // the following params will be set by the API redirect after clicking the
    // email link
    const {
      account_confirmation_success,
      uid,
      client_id,
      token,
    } = this.props.location.query;

    if (account_confirmation_success) {
      writeToLocalStorage({
        'access-token': token,
        client: client_id,
        uid,
      });
      // or connect() it!
      this.context.store.dispatch(validateToken());
    }
  }

  render() {
    return (
      <h1>Hello world</h1>
    );
  }
}

Leveraging the supplied BaseApi class to create your own API-consuming classes:

// util/UsersApi.js
import { BaseApi } from '@zooppa/zooppa-auth';

class UsersApi extends BaseApi {
  index() {
    return this.apiClient.get('/users');
  }

  show(id) {
    return this.apiClient.get(`/users/${id}`);
  }
}

// actions/users.js
export const fetchUsers = () => (dispatch, getState) => {
  dispatch(requestUsers());

  return new UsersApi({ apiRoot: getState().auth.configuration.apiRoot })
    .index({ role, page })
    .then(res => res.json())
    .then(users => dispatch(receiveUsers(users)));
}

Testing

  • npm run test:single -- single test run with coverage check
  • npm run test -- keep running tests on modified files

Linting

This project is meant to be used in a create-react-app application, therefore we are relying on the same linter rules.