Trello clone with Phoenix and React (第六章节)

这篇文章属于基于Phoenix Framework 和React的Trello系列

前端用户登录

后端的作用是处理登录请求,让我们转移到前端,看看如何建立和发送这些请求,以及如何使用返回数据,并允许访问私有路由。

路由文件

继续之前,让我们再次看看React路由文件:

  1. // web/static/js/routes/index.js
  2. import { IndexRoute, Route } from 'react-router';
  3. import React from 'react';
  4. import MainLayout from '../layouts/main';
  5. import AuthenticatedContainer from '../containers/authenticated';
  6. import HomeIndexView from '../views/home';
  7. import RegistrationsNew from '../views/registrations/new';
  8. import SessionsNew from '../views/sessions/new';
  9. import BoardsShowView from '../views/boards/show';
  10. import CardsShowView from '../views/cards/show';
  11. export default (
  12. <Route component={MainLayout}>
  13. <Route path="/sign_up" component={RegistrationsNew} />
  14. <Route path="/sign_in" component={SessionsNew} />
  15. <Route path="/" component={AuthenticatedContainer}>
  16. <IndexRoute component={HomeIndexView} />
  17. <Route path="/boards/:id" component={BoardsShowView}>
  18. <Route path="cards/:id" component={CardsShowView}/>
  19. </Route>
  20. </Route>
  21. </Route>
  22. );

在第四章中,AuthenticatedContainer是为了防止未授权用户访问卡片内容,除非用户合法登录并返回有 jwttoken。

视图组件

现在我们需要创建SessionsNew组件,用于用户登录时需要呈现的表单:

  1. // web/static/js/views/sessions/new.js
  2. import React, {PropTypes} from 'react';
  3. import { connect } from 'react-redux';
  4. import { Link } from 'react-router';
  5. import { setDocumentTitle } from '../../utils';
  6. import Actions from '../../actions/sessions';
  7. class SessionsNew extends React.Component {
  8. componentDidMount() {
  9. setDocumentTitle('Sign in');
  10. }
  11. _handleSubmit(e) {
  12. e.preventDefault();
  13. const { email, password } = this.refs;
  14. const { dispatch } = this.props;
  15. dispatch(Actions.signIn(email.value, password.value));
  16. }
  17. _renderError() {
  18. const { error } = this.props;
  19. if (!error) return false;
  20. return (
  21. <div className="error">
  22. {error}
  23. </div>
  24. );
  25. }
  26. render() {
  27. return (
  28. <div className='view-container sessions new'>
  29. <main>
  30. <header>
  31. <div className="logo" />
  32. </header>
  33. <form onSubmit={::this._handleSubmit}>
  34. {::this._renderError()}
  35. <div className="field">
  36. <input ref="email" type="Email" placeholder="Email" required="true" defaultValue="john@phoenix-trello.com"/>
  37. </div>
  38. <div className="field">
  39. <input ref="password" type="password" placeholder="Password" required="true" defaultValue="12345678"/>
  40. </div>
  41. <button type="submit">Sign in</button>
  42. </form>
  43. <Link to="/sign_up">Create new account</Link>
  44. </main>
  45. </div>
  46. );
  47. }
  48. }
  49. const mapStateToProps = (state) => (
  50. state.session
  51. );
  52. export default connect(mapStateToProps)(SessionsNew);

基本功能是呈现表单和当表单确认时候调用signIn动作creator。同时,也讲连接store,获取通过session reducer更新的store,便于显示用户验证错误。

action creator

下面是用户交互,让我们创建sessions相关的action creator:

  1. // web/static/js/actions/sessions.js
  2. import { routeActions } from 'redux-simple-router';
  3. import Constants from '../constants';
  4. import { Socket } from '../phoenix';
  5. import { httpGet, httpPost, httpDelete } from '../utils';
  6. function setCurrentUser(dispatch, user) {
  7. dispatch({
  8. type: Constants.CURRENT_USER,
  9. currentUser: user,
  10. });
  11. // ...
  12. };
  13. const Actions = {
  14. signIn: (email, password) => {
  15. return dispatch => {
  16. const data = {
  17. session: {
  18. email: email,
  19. password: password,
  20. },
  21. };
  22. httpPost('/api/v1/sessions', data)
  23. .then((data) => {
  24. localStorage.setItem('phoenixAuthToken', data.jwt);
  25. setCurrentUser(dispatch, data.user);
  26. dispatch(routeActions.push('/'));
  27. })
  28. .catch((error) => {
  29. error.response.json()
  30. .then((errorJSON) => {
  31. dispatch({
  32. type: Constants.SESSIONS_ERROR,
  33. error: errorJSON.error,
  34. });
  35. });
  36. });
  37. };
  38. },
  39. // ...
  40. };
  41. export default Actions;

signIn功能,将产生一个POST请求,参数包括email和用户提供的password。如果后端验证成功,将存储返回的jwttoken到localStorage,并发送currentUser JSONstore。如果出现任何用户验证错误,都将发送错误反馈到用户登录表单。

The reducer

让我们创建 session reducer:

  1. // web/static/js/reducers/session.js
  2. import Constants from '../constants';
  3. const initialState = {
  4. currentUser: null,
  5. error: null,
  6. };
  7. export default function reducer(state = initialState, action = {}) {
  8. switch (action.type) {
  9. case Constants.CURRENT_USER:
  10. return { ...state, currentUser: action.currentUser, error: null };
  11. case Constants.SESSIONS_ERROR:
  12. return { ...state, error: action.error };
  13. default:
  14. return state;
  15. }
  16. }

这里我们就不说那么多了。很显然,我们需要修改authenticatedcontainer,就可以获取新state:

验证container

  1. // web/static/js/containers/authenticated.js
  2. import React from 'react';
  3. import { connect } from 'react-redux';
  4. import Actions from '../actions/sessions';
  5. import { routeActions } from 'redux-simple-router';
  6. import Header from '../layouts/header';
  7. class AuthenticatedContainer extends React.Component {
  8. componentDidMount() {
  9. const { dispatch, currentUser } = this.props;
  10. const phoenixAuthToken = localStorage.getItem('phoenixAuthToken');
  11. if (phoenixAuthToken && !currentUser) {
  12. dispatch(Actions.currentUser());
  13. } else if (!phoenixAuthToken) {
  14. dispatch(routeActions.push('/sign_in'));
  15. }
  16. }
  17. render() {
  18. const { currentUser, dispatch } = this.props;
  19. if (!currentUser) return false;
  20. return (
  21. <div className="application-container">
  22. <Header
  23. currentUser={currentUser}
  24. dispatch={dispatch}/>
  25. <div className="main-container">
  26. {this.props.children}
  27. </div>
  28. </div>
  29. );
  30. }
  31. }
  32. const mapStateToProps = (state) => ({
  33. currentUser: state.session.currentUser,
  34. });
  35. export default connect(mapStateToProps)(AuthenticatedContainer);

当这个组件mounte后,如果有验证token,在store中却不是currentUser,会调用currentUser动作creator并从后端获取用户数据。让我们添加这部分代码:

  1. // web/static/js/actions/sessions.js
  2. // ...
  3. const Actions = {
  4. // ...
  5. currentUser: () => {
  6. return dispatch => {
  7. httpGet('/api/v1/current_user')
  8. .then(function(data) {
  9. setCurrentUser(dispatch, data);
  10. })
  11. .catch(function(error) {
  12. console.log(error);
  13. dispatch(routeActions.push('/sign_in'));
  14. });
  15. };
  16. },
  17. // ...
  18. }
  19. // ...

如果用户重新加载浏览器或者是没有之前注销再次访问根URL,数据将会覆盖。根据我们前面的步骤,用户登录后,将在state中设置currentUser,该组件将呈现正常显示标题组件及其嵌套的子路由。

标题组件

这个组件将呈现用户图像和名字,以及卡片链接和注销按钮。

  1. // web/static/js/layouts/header.js
  2. import React from 'react';
  3. import { Link } from 'react-router';
  4. import Actions from '../actions/sessions';
  5. import ReactGravatar from 'react-gravatar';
  6. export default class Header extends React.Component {
  7. constructor() {
  8. super();
  9. }
  10. _renderCurrentUser() {
  11. const { currentUser } = this.props;
  12. if (!currentUser) {
  13. return false;
  14. }
  15. const fullName = [currentUser.first_name, currentUser.last_name].join(' ');
  16. return (
  17. <a className="current-user">
  18. <ReactGravatar email={currentUser.email} https /> {fullName}
  19. </a>
  20. );
  21. }
  22. _renderSignOutLink() {
  23. if (!this.props.currentUser) {
  24. return false;
  25. }
  26. return (
  27. <a href="#" onClick={::this._handleSignOutClick}><i className="fa fa-sign-out"/> Sign out</a>
  28. );
  29. }
  30. _handleSignOutClick(e) {
  31. e.preventDefault();
  32. this.props.dispatch(Actions.signOut());
  33. }
  34. render() {
  35. return (
  36. <header className="main-header">
  37. <nav>
  38. <ul>
  39. <li>
  40. <Link to="/"><i className="fa fa-columns"/> Boards</Link>
  41. </li>
  42. </ul>
  43. </nav>
  44. <Link to='/'>
  45. <span className='logo'/>
  46. </Link>
  47. <nav className="right">
  48. <ul>
  49. <li>
  50. {this._renderCurrentUser()}
  51. </li>
  52. <li>
  53. {this._renderSignOutLink()}
  54. </li>
  55. </ul>
  56. </nav>
  57. </header>
  58. );
  59. }
  60. }

当用户用户点击注销按钮,将调用session动作creator中的signOut方法。让我们添加这些代码:

  1. // web/static/js/actions/sessions.js
  2. // ...
  3. const Actions = {
  4. // ...
  5. signOut: () => {
  6. return dispatch => {
  7. httpDelete('/api/v1/sessions')
  8. .then((data) => {
  9. localStorage.removeItem('phoenixAuthToken');
  10. dispatch({
  11. type: Constants.USER_SIGNED_OUT,
  12. });
  13. dispatch(routeActions.push('/sign_in'));
  14. })
  15. .catch(function(error) {
  16. console.log(error);
  17. });
  18. };
  19. },
  20. // ...
  21. }
  22. // ...

它将发送DELETE请求到后端,成功后将从localStorage中移除phoenixAuthToken并调用USER_SIGNED_OUT动作,重置先前session reducer定义的state中的currentUser

  1. // web/static/js/reducers/session.js
  2. import Constants from '../constants';
  3. const initialState = {
  4. currentUser: null,
  5. error: null,
  6. };
  7. export default function reducer(state = initialState, action = {}) {
  8. switch (action.type) {
  9. // ...
  10. case Constants.USER_SIGNED_OUT:
  11. return initialState;
  12. // ...
  13. }
  14. }

另外

尽管我们已经完成了用户登录过程,我们还有一个关键功能还没有实现,这也是我们编写的所有功能的核心:用户socket和channels。这个非常重要,我特别留了一章单独讲解它,下一章会仔细这个,UserSocket是什么,以及channels如何实现前后端双向连接,实时显示用户改变。同样,别忘记查看运行演示和下载最终的源代码:

演示 源代码