Live Demo---GitHub Repojavascript
上一篇博文中咱們已經實現用戶認證相關的API接口,接下來咱們添加前端的登陸註冊界面並實現用戶認證。css
關於樣式用法的備註:在React項目中,我喜歡做用域在組件內的樣式,也就是將CSS定義在組件所屬的js文件中,並使用行內樣式。我將全局CSS(好比Twitter Bootstrap)只用做基本的頁面元素樣式。html
在JS文件中有許多使用CSS的方法,好比 CSS Modules, Radium, Styled-Components或者直接使用JavaScript對象。在這個項目中咱們採用Aphrodite前端
此次提交,能夠看到咱們是怎麼爲項目配置全局樣式的。下載最新版的bootstrap和font-awesome,建立index.css文件寫入一些基本樣式。並將其所有import到咱們項目的入口文件中。java
咱們須要在App組件中添加兩個新的路由,一個是登陸/login
,另外一個是註冊/signup
react
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組件中獲取輸入的數據。submitting
prop也是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_SUCCESS
和LOGOUT
兩種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 看看目前咱們已經實現的代碼
咱們在Server端已經實現/sessions/refresh
接口。新建一個authenticate
action當用戶刷新頁面時調用。顯然這個調用須要放在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,並跳轉到登陸頁面。
如今你試試,登陸之後刷新頁面已經不會被剔出。
在咱們的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,如今咱們已經實現用戶登陸登出以及首頁的訪問。
這部分就此結束,下一篇將會進入到咱們應用的核心:容許用戶創建聊天室。