📜 ⬆️ ⬇️

Trello Clone on Phoenix and React. Parts 6-7





Now that the back-end is ready to serve authentication requests, let's go to the front-end and see how to create and send these requests and how to use the returned data to allow the user access to personal sections.


Route Files


Before continuing, let's look again at the React routes file:


// web/static/js/routes/index.js import { IndexRoute, Route } from 'react-router'; import React from 'react'; import MainLayout from '../layouts/main'; import AuthenticatedContainer from '../containers/authenticated'; import HomeIndexView from '../views/home'; import RegistrationsNew from '../views/registrations/new'; import SessionsNew from '../views/sessions/new'; import BoardsShowView from '../views/boards/show'; import CardsShowView from '../views/cards/show'; export default ( <Route component={MainLayout}> <Route path="/sign_up" component={RegistrationsNew} /> <Route path="/sign_in" component={SessionsNew} /> <Route path="/" component={AuthenticatedContainer}> <IndexRoute component={HomeIndexView} /> <Route path="/boards/:id" component={BoardsShowView}> <Route path="cards/:id" component={CardsShowView}/> </Route> </Route> </Route> ); 

As we saw in the fourth part , the AuthenticatedContainer prohibit users from accessing the board screens, except when the jwt- token resulting from the authentication process is present and correct.


View component


Now you need to create the SessionNew component, which will draw the application login form:


 import React, {PropTypes} from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router'; import { setDocumentTitle } from '../../utils'; import Actions from '../../actions/sessions'; class SessionsNew extends React.Component { componentDidMount() { setDocumentTitle('Sign in'); } _handleSubmit(e) { e.preventDefault(); const { email, password } = this.refs; const { dispatch } = this.props; dispatch(Actions.signIn(email.value, password.value)); } _renderError() { const { error } = this.props; if (!error) return false; return ( <div className="error"> {error} </div> ); } render() { return ( <div className='view-container sessions new'> <main> <header> <div className="logo" /> </header> <form onSubmit={::this._handleSubmit}> {::this._renderError()} <div className="field"> <input ref="email" type="Email" placeholder="Email" required="true" defaultValue="john@phoenix-trello.com"/> </div> <div className="field"> <input ref="password" type="password" placeholder="Password" required="true" defaultValue="12345678"/> </div> <button type="submit">Sign in</button> </form> <Link to="/sign_up">Create new account</Link> </main> </div> ); } } const mapStateToProps = (state) => ( state.session ); export default connect(mapStateToProps)(SessionsNew); 

In general, this component draws the form and calls the constructor of the signIn action when sending it last. It will also be connected to the repository in order to have access to its properties, which will be updated using the session converter; as a result, we will be able to show the user data verification errors.


Action Creator


Following the direction of user actions, we will create a session action constructor:


 // web/static/js/actions/sessions.js import { routeActions } from 'redux-simple-router'; import Constants from '../constants'; import { Socket } from 'phoenix'; import { httpGet, httpPost, httpDelete } from '../utils'; function setCurrentUser(dispatch, user) { dispatch({ type: Constants.CURRENT_USER, currentUser: user, }); // ... }; const Actions = { signIn: (email, password) => { return dispatch => { const data = { session: { email: email, password: password, }, }; httpPost('/api/v1/sessions', data) .then((data) => { localStorage.setItem('phoenixAuthToken', data.jwt); setCurrentUser(dispatch, data.user); dispatch(routeActions.push('/')); }) .catch((error) => { error.response.json() .then((errorJSON) => { dispatch({ type: Constants.SESSIONS_ERROR, error: errorJSON.error, }); }); }); }; }, // ... }; export default Actions; 

The signIn function will create a POST request that transmits the email and password specified by the user. If the authentication to the back-end is successful, the function saves the received jwt-token to localStorage and sends the localStorage JSON structure to the repository. If for some reason the authentication results in errors, instead, the function will redirect them, and we will be able to show them in the login form of the application.


Converter (reducer)


Create a session converter:


 // web/static/js/reducers/session.js import Constants from '../constants'; const initialState = { currentUser: null, error: null, }; export default function reducer(state = initialState, action = {}) { switch (action.type) { case Constants.CURRENT_USER: return { ...state, currentUser: action.currentUser, error: null }; case Constants.SESSIONS_ERROR: return { ...state, error: action.error }; default: return state; } } 

There is little that can be added, since everything is obvious from the code, so we change the authenticated container so that it can handle the new state:


Container authenticated


 // web/static/js/containers/authenticated.js import React from 'react'; import { connect } from 'react-redux'; import Actions from '../actions/sessions'; import { routeActions } from 'redux-simple-router'; import Header from '../layouts/header'; class AuthenticatedContainer extends React.Component { componentDidMount() { const { dispatch, currentUser } = this.props; const phoenixAuthToken = localStorage.getItem('phoenixAuthToken'); if (phoenixAuthToken && !currentUser) { dispatch(Actions.currentUser()); } else if (!phoenixAuthToken) { dispatch(routeActions.push('/sign_in')); } } render() { const { currentUser, dispatch } = this.props; if (!currentUser) return false; return ( <div className="application-container"> <Header currentUser={currentUser} dispatch={dispatch}/> <div className="main-container"> {this.props.children} </div> </div> ); } } const mapStateToProps = (state) => ({ currentUser: state.session.currentUser, }); export default connect(mapStateToProps)(AuthenticatedContainer); 

If when connecting this component the authentication token already exists, but there is no currentUser in the storage, the component will call the currentUser action currentUser to get user data from the back-end. Add it:


 // web/static/js/actions/sessions.js // ... const Actions = { // ... currentUser: () => { return dispatch => { httpGet('/api/v1/current_user') .then(function(data) { setCurrentUser(dispatch, data); }) .catch(function(error) { console.log(error); dispatch(routeActions.push('/sign_in')); }); }; }, // ... } // ... 

This will close us when the user refreshes the browser page or goes to the root URL again, without having previously logged off. Following what has already been said, after the user is authenticated and the currentUser transferred to the (state) state, this component will start the normal drawing, showing the header component and its own nested child routes.


Header component


This component will draw the gravatar and user name along with a link to the boards and an exit button.


 // web/static/js/layouts/header.js import React from 'react'; import { Link } from 'react-router'; import Actions from '../actions/sessions'; import ReactGravatar from 'react-gravatar'; export default class Header extends React.Component { constructor() { super(); } _renderCurrentUser() { const { currentUser } = this.props; if (!currentUser) { return false; } const fullName = [currentUser.first_name, currentUser.last_name].join(' '); return ( <a className="current-user"> <ReactGravatar email={currentUser.email} https /> {fullName} </a> ); } _renderSignOutLink() { if (!this.props.currentUser) { return false; } return ( <a href="#" onClick={::this._handleSignOutClick}><i className="fa fa-sign-out"/> Sign out</a> ); } _handleSignOutClick(e) { e.preventDefault(); this.props.dispatch(Actions.signOut()); } render() { return ( <header className="main-header"> <nav> <ul> <li> <Link to="/"><i className="fa fa-columns"/> Boards</Link> </li> </ul> </nav> <Link to='/'> <span className='logo'/> </Link> <nav className="right"> <ul> <li> {this._renderCurrentUser()} </li> <li> {this._renderSignOutLink()} </li> </ul> </nav> </header> ); } } 

When a user singOut exit button, the singOut method of the session action constructor is called. Add this method:


 // web/static/js/actions/sessions.js // ... const Actions = { // ... signOut: () => { return dispatch => { httpDelete('/api/v1/sessions') .then((data) => { localStorage.removeItem('phoenixAuthToken'); dispatch({ type: Constants.USER_SIGNED_OUT, }); dispatch(routeActions.push('/sign_in')); }) .catch(function(error) { console.log(error); }); }; }, // ... } // ... 

It will send a DELETE request to the back-end and, if successful, will remove the phoenixAuthToken from localStorage , and also send an USER_SIGNED_OUT action that currentUser in the (state) state using the previously described session converter:


 // web/static/js/reducers/session.js import Constants from '../constants'; const initialState = { currentUser: null, error: null, }; export default function reducer(state = initialState, action = {}) { switch (action.type) { // ... case Constants.USER_SIGNED_OUT: return initialState; // ... } } 

Something else


Although we are done with the process of authenticating and logging the user into the application, we have not yet implemented the key functionality that will become the basis of all the future features that we will program: user sockets and channels . This moment is so important that I would rather prefer to leave it for the next part, where we will see what the userSocket looks like and how to connect to it, so that we have bi-directional channels between the front-end and back-end, showing the changes in real time .



Sockets and channels


Original


In the previous section, we completed the authentication process and are now ready to start the fun. From now on, to connect the front-end and back-end, we will largely rely on Phoenix's real-time capabilities. Users will be notified of any events affecting their whiteboards, and the changes will be automatically displayed on the screen.


We can represent channels (channels) as a whole as controllers. But unlike processing the request and returning the result in one connection, they handle bidirectional events on a given topic that can be transmitted to several connected recipients. To configure them, Phoenix uses socket handlers (socket handlers), which authenticate and identify the connection to the socket, as well as describe the routes of the channels that determine which channel processes the corresponding request.


User socket


When creating a new Phoenix application, it automatically creates for us the initial configuration of the socket:


 # lib/phoenix_trello/endpoint.ex defmodule PhoenixTrello.Endpoint do use Phoenix.Endpoint, otp_app: :phoenix_trello socket "/socket", PhoenixTrello.UserSocket # ... end 

UserSocket is also UserSocket , but we will need to make some changes to it in order to process the necessary messages:


 # web/channels/user_socket.ex defmodule PhoenixTrello.UserSocket do use Phoenix.Socket alias PhoenixTrello.{Repo, User} # Channels channel "users:*", PhoenixTrello.UserChannel channel "boards:*", PhoenixTrello.BoardChannel # Transports transport :websocket, Phoenix.Transports.WebSocket transport :longpoll, Phoenix.Transports.LongPoll # ... end 

In fact, we will have two different channels:



We also need to implement the connect and id functions, which will look like this:


 # web/channels/user_socket.ex defmodule PhoenixTrello.UserSocket do # ... def connect(%{"token" => token}, socket) do case Guardian.decode_and_verify(token) do {:ok, claims} -> case GuardianSerializer.from_token(claims["sub"]) do {:ok, user} -> {:ok, assign(socket, :current_user, user)} {:error, _reason} -> :error end {:error, _reason} -> :error end end def connect(_params, _socket), do: :error def id(socket), do: "users_socket:#{socket.assigns.current_user.id}" end 

When you call the connect function ( which happens automatically when you connect to the socket - approx. Translator ) with token as a parameter, it will check the token, retrieve user data from the token using the GuardianSerializer we created in Part 3 , and store this data in the socket, that they will, if necessary, be available in the channel. Moreover, it also prohibits unauthenticated users from connecting to the socket.


Note translator

Note the two descriptions of the connect function: def connect(%{"token" => token}, socket) do ... end and def connect(_params, _socket), do: :error . Thanks to the pattern matching mechanism, the first option will be called if the associative array passed the first parameter has the key "token" (and the value associated with this key will be in a variable called token), and the second - in any other cases. The connect function is called by the framework automatically when connected to a socket.


The id function is used to identify the current connection to the socket and can be used, for example, to terminate all active channels and sockets for a given user. If desired, this can be done from any part of the application by sending a "disconnect" message by calling PhoenixTrello.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})


By the way, using <AppName>.Endpoint.broadcast(topic, message, payload) you can send a message not only about disconnecting the user, but generally any message to all users who subscribe to the corresponding topic. In this case, the topic is a string with a theme (for example, "boards:877" ), message is a string with a message (for example, "boards:update" ), and payload is an associative data array that will be converted to json before being sent. . For example, you can send users who are online, any changes made using REST api, directly from the controller or from any other process.


User channel


After we set up the socket, let's move to the UserChannel , which is very simple:


 # web/channels/user_channel.ex defmodule PhoenixTrello.UserChannel do use PhoenixTrello.Web, :channel def join("users:" <> user_id, _params, socket) do {:ok, socket} end end 

This channel will allow us to transmit any message associated with the user, from anywhere, processing it on the front-end. In our particular case, we will use it to transfer data about the board to which the user was added as a participant, so that we can place this new board on this user’s list. We can also use the channel to display notifications about other boards that the user owns and for anything else that comes to your head.


Connection to socket and channel


Before proceeding, recall what we did in the previous section ... after authenticating the user, regardless of whether the login form was used or the previously saved phoenixAuthToken , we need to get the currentUser data to forward them to the Redux store and be able to show avatar and username in title. This looks like a good place to connect to the socket and the channel as well, so let's do some refactoring:


 // web/static/js/actions/sessions.js import Constants from '../constants'; import { Socket } from 'phoenix'; // ... export function setCurrentUser(dispatch, user) { dispatch({ type: Constants.CURRENT_USER, currentUser: user, }); const socket = new Socket('/socket', { params: { token: localStorage.getItem('phoenixAuthToken') }, }); socket.connect(); const channel = socket.channel(`users:${user.id}`); channel.join().receive('ok', () => { dispatch({ type: Constants.SOCKET_CONNECTED, socket: socket, channel: channel, }); }); }; // ... 

After the user data is redirected, we create a new Socket object from the Phoenix JavaScript library, passing in the phoenixAuthToken parameter, which is required to establish the connection, and then call the connect function. We continue to create a new user channel (user channel ) and join it. When we SOCKET_CONNECTED ok message in response to the join , we direct the SOCKET_CONNECTED action to save both the socket and the channel in the repository:


 // web/static/js/reducers/session.js import Constants from '../constants'; const initialState = { currentUser: null, socket: null, channel: null, error: null, }; export default function reducer(state = initialState, action = {}) { switch (action.type) { case Constants.CURRENT_USER: return { ...state, currentUser: action.currentUser, error: null }; case Constants.USER_SIGNED_OUT: return initialState; case Constants.SOCKET_CONNECTED: return { ...state, socket: action.socket, channel: action.channel }; case Constants.SESSIONS_ERROR: return { ...state, error: action.error }; default: return state; } } 

The main reason for storing these objects is that we need them in many places, so storing in a state makes their components accessible via properties ( props ).


After authenticating the user, connecting to the socket and joining the channel, the AuthenticatedContainer will HomeIndexView , where we will show all the boards owned by the user, as well as those to which he was invited as a participant. In the next section, we will reveal how to create a new board and invite existing users, using channels to transfer the resulting data to the involved users.


In the meantime, do not forget to take a look at the live demo and source code of the final result.


')

Source: https://habr.com/ru/post/308248/


All Articles