Let’ s Build |> 使用 Elixir,Phoenix 和 React 打造克隆版
Let’ s Build |> 使用 Elixir,Phoenix 和 React 打造克隆版的 Slack (part 3?—?Frontend Authentication)
上一篇博文中我们已经实现用户认证相关的API接口,接下来我们添加前端的登录注册界面并实现用户认证。 关于样式用法的备注:在React项目中,我喜欢作用域在组件内的样式,也就是将CSS定义在组件所属的js文件中,并使用行内样式。我将全局CSS(比如Twitter Bootstrap)只用作基本的页面元素样式。 在JS文件中有许多使用CSS的方法,比如 CSS Modules,Radium,Styled-Components或者直接使用JavaScript对象。在这个项目中我们采用Aphrodite
我们需要在App组件中添加两个新的路由,一个是登录 sling/web/src/containers/App/index.js // @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中提交。 sling/web/src/containers/Signup/index.js // @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.js // @flow import React,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,.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中采用 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,} class LoginForm extends Component { props: Props handleSubmit = data => this.props.onSubmit(data); render() { const { handleSubmit,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',})(LoginForm); 上述表单组件均采用redux-form,这也是我们能够获取输入数据的原因。 自定义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组件需要从 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',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',body,patch(url,{ method: 'PATCH',delete(url) { return fetch(`${API}${url}`,{ method: 'DELETE',}; 使用这些helper函数,在redux action中只需调用 create-react-app 支持 REACT_APP_API_URL=http://localhost:4000/api 当用户注册或登录成功,action会发起 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,}; 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 处理 sling/web/src/containers/Home/index.js // @flow import React,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,可直接路由到注册和登录页面。以上只是理论实现,当你尝试注册时你会发现报错 为处理这个跨域错误,我们需要安装第三方库 #above content plug CORSPlug plug Sling.Router end 最后重启Phoenix Server即可,这样就解决了跨域问题。 目前,用户可以登录成功,但是当刷新页面时就会被剔出。接下来我们就解决这个问题。
保存用户会话(Persisting User Sessions)我们在Server端已经实现 sling/web/src/containers/App/index.js // @flow import React,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); 组件的钩子函数 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'; }); }
现在你试试,登录以后刷新页面已经不会被剔出。
路由保护(Protecting Routes)在我们的APP中有这样的要求,登录用户才能看到home页面。未登录的用户只能看到注册和登录页面, 前面我们已经实现了基本的路由跳转。但是React-router v4还提供了一些新的功能。比如可以直接渲染 下面我们实现 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,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,} const RedirectAuthenticated = ({ pattern,}: 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为例,当认证请求已经发起,但是认证是否成功还未知,这种中间状态就需要 现在我们来修改App组件,使用自定义组件替换React-router的<Match />。 sling/web/src/containers/App/index.js // @flow import React,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,} 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' }); 在认证的流程中,首先发起 sling/web/src/reducers/session.js const initialState = { isAuthenticated: false,willAuthenticate: true,action) { switch (action.type) { case 'AUTHENTICATION_REQUEST': return { ...state,}; case 'AUTHENTICATION_SUCCESS': return { ...state,willAuthenticate: false,}; case 'AUTHENTICATION_FAILURE': return { ...state,}; default: return state; } } ok,现在我们已经实现用户登录登出以及首页的访问。
这部分就此结束,下一篇将会进入到我们应用的核心:允许用户建立聊天室。 首发地址:http://blog.zhulinpinyu.com/2017/06/28/lets-build-a-slack-clone-with-elixir-phoenix-and-react-part-3-frontend-authentication/ (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |