Let’ s Build |> 使用 Elixir,Phoenix 和 React 打造克隆版的 Slack (part 3 )

Let’ s Build |> 使用 Elixir,Phoenix 和 React 打造克隆版的 Slack (part 3 — Frontend Authentication)

Live Demo---GitHub Repojavascript

上一篇博文中咱們已經實現用戶認證相關的API接口,接下來咱們添加前端的登陸註冊界面並實現用戶認證。css

關於樣式用法的備註:在React項目中,我喜歡做用域在組件內的樣式,也就是將CSS定義在組件所屬的js文件中,並使用行內樣式。我將全局CSS(好比Twitter Bootstrap)只用做基本的頁面元素樣式。html

在JS文件中有許多使用CSS的方法,好比 CSS Modules, Radium, Styled-Components或者直接使用JavaScript對象。在這個項目中咱們採用Aphrodite前端

此次提交,能夠看到咱們是怎麼爲項目配置全局樣式的。下載最新版的bootstrapfont-awesome,建立index.css文件寫入一些基本樣式。並將其所有import到咱們項目的入口文件中。java

咱們須要在App組件中添加兩個新的路由,一個是登陸/login,另外一個是註冊/signupreact

sling/web/src/containers/App/index.jsgit

// @flow
import React, { Component } from 'react';
import { BrowserRouter, Match, Miss } from 'react-router';
import Home from '../Home';
import NotFound from '../../components/NotFound';
import Login from '../Login';
import Signup from '../Signup';

class App extends Component {
  render() {
    return (
      <BrowserRouter>
        <div style={{ display: 'flex', flex: '1' }}>
          <Match exactly pattern="/" component={Home} />
          <Match pattern="/login" component={Login} />
          <Match pattern="/signup" component={Signup} />
          <Miss component={NotFound} />
        </div>
      </BrowserRouter>
    );
  }
}

export default App;

Login和Signup組件比較類似,都包含一些基本的佈局,而且都是從子表單中傳遞數據到組件的action中提交。github

sling/web/src/containers/Signup/index.jsweb

// @flow
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { signup } from '../../actions/session';
import SignupForm from '../../components/SignupForm';
import Navbar from '../../components/Navbar';

type Props = {
  signup: () => void,
}

class Signup extends Component {
  static contextTypes = {
    router: PropTypes.object,
  }

  props: Props

  handleSignup = data => this.props.signup(data, this.context.router);

  render() {
    return (
      <div style={{ flex: '1' }}>
        <Navbar />
        <SignupForm onSubmit={this.handleSignup} />
      </div>
    );
  }
}

export default connect(null, { signup })(Signup);

sling/web/src/containers/Login/index.jsjson

// @flow
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { login } from '../../actions/session';
import LoginForm from '../../components/LoginForm';
import Navbar from '../../components/Navbar';

type Props = {
  login: () => void,
}

class Login extends Component {
  static contextTypes = {
    router: PropTypes.object,
  }

  props: Props

  handleLogin = data => this.props.login(data, this.context.router);

  render() {
    return (
      <div style={{ flex: '1' }}>
        <Navbar />
        <LoginForm onSubmit={this.handleLogin} />
      </div>
    );
  }
}

export default connect(null, { login })(Login);

如你所見,咱們引入NavBar組件,目的是讓咱們的頁面更好看一些。

sling/web/src/components/Navbar/index.js

// @flow
import React from 'react';
import { Link } from 'react-router';
import { css, StyleSheet } from 'aphrodite';

const styles = StyleSheet.create({
  navbar: {
    display: 'flex',
    alignItems: 'center',
    padding: '0 1rem',
    height: '70px',
    background: '#fff',
    boxShadow: '0 1px 1px rgba(0,0,0,.1)',
  },

  link: {
    color: '#555459',
    fontSize: '22px',
    fontWeight: 'bold',
    ':hover': {
      textDecoration: 'none',
    },
    ':focus': {
      textDecoration: 'none',
    },
  },
});

const Navbar = () =>
  <nav className={css(styles.navbar)}>
    <Link to="/" className={css(styles.link)}>Sling</Link>
  </nav>;

export default Navbar;

react-router使用說明:react項目中,之前咱們使用react-router-redux, 它在action中採用dispatch(push(/login))方式實現路由跳轉。可是在v4版本的react-router中已經沒有這個功能,爲了實現上述跳轉功能咱們必須傳遞參數this.context.router到action中實現跳轉。

Signup組件與Login組件很是相近,SignupForm和LoginForm也很是類似。

sling/web/src/components/SignupForm/index.js

// @flow
import React, { Component } from 'react';
import { Field, reduxForm } from 'redux-form';
import { Link } from 'react-router';
import { css, StyleSheet } from 'aphrodite';
import Input from '../Input';

const styles = StyleSheet.create({
  card: {
    maxWidth: '500px',
    padding: '3rem 4rem',
    margin: '2rem auto',
  },
});

type Props = {
  onSubmit: () => void,
  submitting: boolean,
  handleSubmit: () => void,
}

class SignupForm extends Component {
  props: Props

  handleSubmit = data => this.props.onSubmit(data);

  render() {
    const { handleSubmit, submitting } = this.props;

    return (
      <form
        className={`card ${css(styles.card)}`}
        onSubmit={handleSubmit(this.handleSubmit)}
      >
        <h3 style={{ marginBottom: '2rem', textAlign: 'center' }}>Create an account</h3>
        <Field
          name="username"
          type="text"
          component={Input}
          placeholder="Username"
          className="form-control"
        />
        <Field
          name="email"
          type="email"
          component={Input}
          placeholder="Email"
          className="form-control"
        />
        <Field
          name="password"
          type="password"
          component={Input}
          placeholder="Password"
          className="form-control"
        />
        <button
          type="submit"
          disabled={submitting}
          className="btn btn-block btn-primary"
        >
          {submitting ? 'Submitting...' : 'Sign up'}
        </button>
        <hr style={{ margin: '2rem 0' }} />
        <Link to="/login" className="btn btn-block btn-secondary">
          Login to your account
        </Link>
      </form>
    );
  }
}

const validate = (values) => {
  const errors = {};
  if (!values.username) {
    errors.username = 'Required';
  }
  if (!values.email) {
    errors.email = 'Required';
  }
  if (!values.password) {
    errors.password = 'Required';
  } else if (values.password.length < 6) {
    errors.password = 'Minimum of 6 characters';
  }
  return errors;
};

export default reduxForm({
  form: 'signup',
  validate,
})(SignupForm);

sling/web/src/components/LoginForm/index.js

// @flow
import React, { Component } from 'react';
import { Field, reduxForm } from 'redux-form';
import { Link } from 'react-router';
import { css, StyleSheet } from 'aphrodite';
import Input from '../Input';

const styles = StyleSheet.create({
  card: {
    maxWidth: '500px',
    padding: '3rem 4rem',
    margin: '2rem auto',
  },
});

type Props = {
  onSubmit: () => void,
  handleSubmit: () => void,
  submitting: boolean,
}

class LoginForm extends Component {
  props: Props

  handleSubmit = data => this.props.onSubmit(data);

  render() {
    const { handleSubmit, submitting } = this.props;

    return (
      <form
        className={`card ${css(styles.card)}`}
        onSubmit={handleSubmit(this.handleSubmit)}
      >
        <h3 style={{ marginBottom: '2rem', textAlign: 'center' }}>Login to Sling</h3>
        <Field name="email" type="text" component={Input} placeholder="Email" />
        <Field name="password" type="password" component={Input} placeholder="Password" />
        <button
          type="submit"
          disabled={submitting}
          className="btn btn-block btn-primary"
        >
          {submitting ? 'Logging in...' : 'Login'}
        </button>
        <hr style={{ margin: '2rem 0' }} />
        <Link to="/signup" className="btn btn-block btn-secondary">
          Create a new account
        </Link>
      </form>
    );
  }
}

const validate = (values) => {
  const errors = {};
  if (!values.email) {
    errors.email = 'Required';
  }
  if (!values.password) {
    errors.password = 'Required';
  }
  return errors;
};

export default reduxForm({
  form: 'login',
  validate,
})(LoginForm);

上述表單組件均採用redux-form, 這也是咱們可以獲取輸入數據的緣由。this.props.handleSubmit是redux-form提供的特定屬性, 使咱們可以基於name從Field組件中獲取輸入的數據。submittingprop也是redux-form提供的特定prop,其值爲布爾型,onSubmit被觸發時submitting會被設爲true。

自定義Field 組件,包含input以及顯示error功能。

sling/web/src/components/Input/index.js

// @flow
import React from 'react';

type Props = {
  input: Object,
  label?: string,
  type?: string,
  placeholder?: string,
  style?: Object,
  meta: Object,
}

const Input = ({ input, label, type, placeholder, style, meta }: Props) =>
  <div style={{ marginBottom: '1rem' }}>
    {label && <label htmlFor={input.name}>{label}</label>}
    <input
      {...input}
      type={type}
      placeholder={placeholder}
      className="form-control"
      style={style && style}
    />
    {meta.touched && meta.error &&
      <div style={{ fontSize: '85%', color: 'rgb(255,59,48)' }}>{meta.error}</div>
    }
  </div>;

export default Input;

Signup組件和Login組件須要從session.js中import action

sling/web/src/actions/session.js

import { reset } from 'redux-form';
import api from '../api';

function setCurrentUser(dispatch, response) {
  localStorage.setItem('token', JSON.stringify(response.meta.token));
  dispatch({ type: 'AUTHENTICATION_SUCCESS', response });
}

export function login(data, router) {
  return dispatch => api.post('/sessions', data)
    .then((response) => {
      setCurrentUser(dispatch, response);
      dispatch(reset('login'));
      router.transitionTo('/');
    });
}

export function signup(data, router) {
  return dispatch => api.post('/users', data)
    .then((response) => {
      setCurrentUser(dispatch, response);
      dispatch(reset('signup'));
      router.transitionTo('/');
    });
}

export function logout(router) {
  return dispatch => api.delete('/sessions')
    .then(() => {
      localStorage.removeItem('token');
      dispatch({ type: 'LOGOUT' });
      router.transitionTo('/login');
    });
}

爲使redux action方便發送http請求,一般將其封裝在API工具文件中,咱們也遵守規範實現。

sling/web/src/api/index.js

const API = process.env.REACT_APP_API_URL;

function headers() {
  const token = JSON.parse(localStorage.getItem('token'));

  return {
    Accept: 'application/json',
    'Content-Type': 'application/json',
    Authorization: `Bearer: ${token}`,
  };
}

function parseResponse(response) {
  return response.json().then((json) => {
    if (!response.ok) {
      return Promise.reject(json);
    }
    return json;
  });
}

function queryString(params) {
  const query = Object.keys(params)
                      .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
                      .join('&');
  return `${query.length ? '?' : ''}${query}`;
}

export default {
  fetch(url, params = {}) {
    return fetch(`${API}${url}${queryString(params)}`, {
      method: 'GET',
      headers: headers(),
    })
    .then(parseResponse);
  },

  post(url, data) {
    const body = JSON.stringify(data);

    return fetch(`${API}${url}`, {
      method: 'POST',
      headers: headers(),
      body,
    })
    .then(parseResponse);
  },

  patch(url, data) {
    const body = JSON.stringify(data);

    return fetch(`${API}${url}`, {
      method: 'PATCH',
      headers: headers(),
      body,
    })
    .then(parseResponse);
  },

  delete(url) {
    return fetch(`${API}${url}`, {
      method: 'DELETE',
      headers: headers(),
    })
    .then(parseResponse);
  },
};

使用這些helper函數,在redux action中只需調用api.post(/url, data)而後處理返回結果便可。另,每次請求header中均已包含來自localStorage的jwt token。

create-react-app 支持.env環境變量。咱們在根路徑下建立.env文件, 存入REACT_APP_*=xxx,運行時便可經過process.env.REACT_APP_*讀取值,看下咱們的實現。

REACT_APP_API_URL=http://localhost:4000/api

當用戶註冊或登陸成功,action會發起AUTHENTICATION_SUCCESS。咱們須要建立reducer來監聽action分發出的數據並將其存儲到redux state中。

sling/web/src/reducers/session.js

const initialState = {
  isAuthenticated: false,
  currentUser: {},
};

export default function (state = initialState, action) {
  switch (action.type) {
    case 'AUTHENTICATION_SUCCESS':
      return {
        ...state,
        isAuthenticated: true,
        currentUser: action.response.data,
      };
    case 'LOGOUT':
      return {
        ...state,
        isAuthenticated: false,
        currentUser: {},
      };
    default:
      return state;
  }
}

而後把session reducer放入總的reducer中,

sling/web/src/reducers/index.js

import { combineReducers } from 'redux';
import { reducer as form } from 'redux-form';
import session from './session';

const appReducer = combineReducers({
  form,
  session,
});

export default function (state, action) {
  if (action.type === 'LOGOUT') {
    return appReducer(undefined, action);
  }
  return appReducer(state, action);
}

目前session reducer 處理AUTHENTICATION_SUCCESSLOGOUT兩種action,並改變了isAuthenticated和currentUser的值。接下來咱們將redux state connect到Home組件中,當用戶登陸時就能夠看到當前用戶。

sling/web/src/containers/Home/index.js

// @flow
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { logout } from '../../actions/session';
import Navbar from '../../components/Navbar';

type Props = {
  logout: () => void,
  currentUser: Object,
  isAuthenticated: boolean,
}

class Home extends Component {
  static contextTypes = {
    router: PropTypes.object,
  }

  props: Props

  handleLogout = () => this.props.logout(this.context.router);

  render() {
    const { currentUser, isAuthenticated } = this.props;

    return (
      <div style={{ flex: '1' }}>
        <Navbar />
        <ul>
          <li><Link to="/login">Login</Link></li>
          <li><Link to="/signup">Signup</Link></li>
        </ul>
        {isAuthenticated &&
          <div>
            <span>{currentUser.username}</span>
            <button type="button" onClick={this.handleLogout}>Logout</button>
          </div>
        }
      </div>
    );
  }
}

export default connect(
  state => ({
    isAuthenticated: state.session.isAuthenticated,
    currentUser: state.session.currentUser,
  }),
  { logout }
)(Home);

到目前爲止,當用戶登陸後,我會顯示當前用戶的username。而且添加link,可直接路由到註冊和登陸頁面。以上只是理論實現,當你嘗試註冊時你會發現報錯No ‘Access-Control-Allow-Origin’ header is present on the requested resource.這是一個http 請求跨域的錯誤,咱們須要在server端處理它。

爲處理這個跨域錯誤,咱們須要安裝第三方庫 {:cors_plug, "~> 1.1"},而後在sling/api/sling/endpoint.ex中添加配置。

#above content
 
    plug CORSPlug

    plug Sling.Router
 end

最後重啓Phoenix Server便可,這樣就解決了跨域問題。

目前,用戶能夠登陸成功,可是當刷新頁面時就會被剔出。接下來咱們就解決這個問題。

Commit 看看目前咱們已經實現的代碼

保存用戶會話(Persisting User Sessions)

咱們在Server端已經實現/sessions/refresh接口。新建一個authenticateaction當用戶刷新頁面時調用。顯然這個調用須要放在App組件中,由於它是咱們的根組件,也就是頁面刷新會首先加載這個組件。

sling/web/src/containers/App/index.js

// @flow
import React, { Component } from 'react';
import { BrowserRouter, Match, Miss } from 'react-router';
import { connect } from 'react-redux';
import { authenticate } from '../../actions/session';
import Home from '../Home';
import NotFound from '../../components/NotFound';
import Login from '../Login';
import Signup from '../Signup';

type Props = {
  authenticate: () => void,
}

class App extends Component {
  componentDidMount() {
    const token = localStorage.getItem('token');

    if (token) {
      this.props.authenticate();
    }
  }

  props: Props

  render() {
    return (
      <BrowserRouter>
        <div style={{ display: 'flex', flex: '1' }}>
          <Match exactly pattern="/" component={Home} />
          <Match pattern="/login" component={Login} />
          <Match pattern="/signup" component={Signup} />
          <Miss component={NotFound} />
        </div>
      </BrowserRouter>
    );
  }
}

export default connect(
  null,
  { authenticate }
)(App);

組件的鉤子函數componentDidMount會首先檢查localStorage中的token。若token存在就會調用authenticate函數請server端認證用戶。

sling/web/src/actions/session.js

export function authenticate() {
  return dispatch => api.post('/sessions/refresh')
    .then((response) => {
      setCurrentUser(dispatch, response);
    })
    .catch(() => {
      localStorage.removeItem('token');
      window.location = '/login';
    });
}

api.post(‘/sessions/refresh’)沒有發送任何數據,其實默認在header中包含token數據。因此Guardian纔會找到token並實現用戶認證。若認證失敗就會從localStorage移除token,並跳轉到登陸頁面。

如今你試試,登陸之後刷新頁面已經不會被剔出。

對比一下代碼變化Commit

路由保護(Protecting Routes)

在咱們的APP中有這樣的要求,登陸用戶才能看到home頁面。未登陸的用戶只能看到註冊和登陸頁面,

前面咱們已經實現了基本的路由跳轉。可是React-router v4還提供了一些新的功能。好比能夠直接渲染<Redirect/> 組件。咱們能夠向無狀態組件中傳入參數而後決定是渲染<Match />組件仍是渲染<Redirect />組件。官方文檔

下面咱們實現<MatchAuthenticated /><RedirectAuthenticated />兩個stateless 組件

sling/web/src/components/MatchAuthenticated/index.js

// @flow
import React from 'react';
import { Match, Redirect } from 'react-router';

type Props = {
  component: any,
  pattern: string,
  exactly?: boolean,
  isAuthenticated: boolean,
  willAuthenticate: boolean,
}

const MatchAuthenticated = ({
  pattern,
  exactly,
  isAuthenticated,
  willAuthenticate,
  component: Component,
}: Props) =>
  <Match
    exactly={exactly}
    pattern={pattern}
    render={(props) => {
      if (isAuthenticated) { return <Component {...props} />; }
      if (willAuthenticate) { return null; }
      if (!willAuthenticate && !isAuthenticated) { return <Redirect to={{ pathname: '/login' }} />; }
      return null;
    }}
  />;

export default MatchAuthenticated;

sling/web/src/components/RedirectAuthenticated/index.js

// @flow
import React from 'react';
import { Match, Redirect } from 'react-router';

type Props = {
  component: any,
  pattern: string,
  exactly?: boolean,
  isAuthenticated: boolean,
  willAuthenticate: boolean,
}

const RedirectAuthenticated = ({
  pattern,
  exactly,
  isAuthenticated,
  willAuthenticate,
  component: Component,
}: Props) =>
  <Match
    exactly={exactly}
    pattern={pattern}
    render={(props) => {
      if (isAuthenticated) { return <Redirect to={{ pathname: '/' }} />; }
      if (willAuthenticate) { return null; }
      if (!willAuthenticate && !isAuthenticated) { return <Component {...props} />; }
      return null;
    }}
  />;

export default RedirectAuthenticated;

在構建以上組件的過程當中,咱們發現須要傳遞一些像willAuthenticate這樣的參數以保證路徑跳轉正常運行。以willAuthenticate爲例,當認證請求已經發起,可是認證是否成功還未知,這種中間狀態就須要willAuthenticate=true來處理,以保證不會出現錯誤的頁面跳轉。

如今咱們來修改App組件,使用自定義組件替換React-router的<Match />。

sling/web/src/containers/App/index.js

// @flow
import React, { Component } from 'react';
import { BrowserRouter, Miss } from 'react-router';
import { connect } from 'react-redux';
import { authenticate, unauthenticate } from '../../actions/session';
import Home from '../Home';
import NotFound from '../../components/NotFound';
import Login from '../Login';
import Signup from '../Signup';
import MatchAuthenticated from '../../components/MatchAuthenticated';
import RedirectAuthenticated from '../../components/RedirectAuthenticated';

type Props = {
  authenticate: () => void,
  unauthenticate: () => void,
  isAuthenticated: boolean,
  willAuthenticate: boolean,
}

class App extends Component {
  componentDidMount() {
    const token = localStorage.getItem('token');

    if (token) {
      this.props.authenticate();
    } else {
      this.props.unauthenticate();
    }
  }

  props: Props

  render() {
    const { isAuthenticated, willAuthenticate } = this.props;
    const authProps = { isAuthenticated, willAuthenticate };

    return (
      <BrowserRouter>
        <div style={{ display: 'flex', flex: '1' }}>
          <MatchAuthenticated exactly pattern="/" component={Home} {...authProps} />
          <RedirectAuthenticated pattern="/login" component={Login} {...authProps} />
          <RedirectAuthenticated pattern="/signup" component={Signup} {...authProps} />
          <Miss component={NotFound} />
        </div>
      </BrowserRouter>
    );
  }
}

export default connect(
  state => ({
    isAuthenticated: state.session.isAuthenticated,
    willAuthenticate: state.session.willAuthenticate,
  }),
  { authenticate, unauthenticate }
)(App);

咱們已經替換掉Match組件,並傳遞必要的認證參數。最後還須要添加一個unauthenticate action,當認證失敗時用於改變willAuthenticate的值。

sling/web/src/actions/session.js

export function authenticate() {
  return (dispatch) => {
    dispatch({ type: 'AUTHENTICATION_REQUEST' });
    return api.post('/sessions/refresh')
      .then((response) => {
        setCurrentUser(dispatch, response);
      })
      .catch(() => {
        localStorage.removeItem('token');
        window.location = '/login';
      });
  };
}

export const unauthenticate = () => ({ type: 'AUTHENTICATION_FAILURE' });

在認證的流程中,首先發起 AUTHENTICATION_REQUEST 開始認證,執行完setCurrentUser函數發起AUTHENTICATION_SUCCESS說明認證成功,認證失敗則會發起AUTHENTICATION_FAILURE。根據這個流程咱們相應的修正 session reducer。

sling/web/src/reducers/session.js

const initialState = {
  isAuthenticated: false,
  willAuthenticate: true,
  currentUser: {},
};

export default function (state = initialState, action) {
  switch (action.type) {
    case 'AUTHENTICATION_REQUEST':
      return {
        ...state,
        willAuthenticate: true,
      };
    case 'AUTHENTICATION_SUCCESS':
      return {
        ...state,
        willAuthenticate: false,
        isAuthenticated: true,
        currentUser: action.response.data,
      };
    case 'AUTHENTICATION_FAILURE':
      return {
        ...state,
        willAuthenticate: false,
      };
    case 'LOGOUT':
      return {
        ...state,
        willAuthenticate: false,
        isAuthenticated: false,
        currentUser: {},
      };
    default:
      return state;
  }
}

ok,如今咱們已經實現用戶登陸登出以及首頁的訪問。

Commit 來對比下代碼變化

這部分就此結束,下一篇將會進入到咱們應用的核心:容許用戶創建聊天室。

首發地址:http://blog.zhulinpinyu.com/2017/06/28/lets-build-a-slack-clone-with-elixir-phoenix-and-react-part-3-frontend-authentication/

相關文章
相關標籤/搜索