
- Введение и выбор стека технологий
- Начальная настройка проекта Phoenix Framework
- Модель User и JWT-аутентификация
- Front-end для регистрации на React и Redux
- Начальное заполнение базы данных и контроллер для входа в приложение
- Аутентификация на front-end на React и Redux
- Настраиваем сокеты и каналы
- Выводим список и создаём новые доски
- Добавляем новых пользователей досок
- Отслеживаем подключённых пользователей досок
- Добавляем списки и карточки
- Выкладываем проект на Heroku
Теперь, когда back-end готов обслуживать запросы на аутентификацию, давайте перейдём к front-end и посмотрим, как создать и отправить эти запросы и как использовать возвращённые данные для того, чтобы разрешить пользователю доступ к личным разделам.
Файлы маршрутов
Прежде, чем продолжить, посмотрим снова на файл маршрутов React:
// 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> );
Как мы видели в четвертой части, AuthenticatedContainer запретит пользователям доступ к экранам досок, кроме случаев, когда jwt-токен, полученный в результате процесса аутентификации, присутствует и корректен.
Компонент представления (view component)
Сейчас необходимо создать компонент SessionNew, который будет отрисовывать форму входа в приложение:
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);
В целом этот компонент отрисовывает форму и вызывает конструктор действия signIn при отправке последней. Он также будет подключён к хранилищу, чтобы иметь доступ к своим свойствам, каковые будут обновляться с помощью преобразователя сессии; в результате мы сможем показать пользователю ошибки проверки данных.
Конструктор действия (action creator)
Следуя по направлению действий пользователя, создадим конструктор действия сессий:
// 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;
Функция signIn создаст POST-запрос, передающий email и пароль, указанные пользователем. Если аутентификация на back-end прошла успешно, функция сохранит полученный jwt-токен в localStorage и направит JSON-структуру currentUser в хранилище. Если по какой-то причине результатом аутентификации будут ошибки, вместо этого функция перенаправит именно их, а мы сможем показать их в форме входа в приложение.
Преобразователь (reducer)
Создадим преобразователь session:
// 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; } }
Тут мало что можно добавить, поскольку всё очевидно из кода, поэтому изменим контейнер authenticated, чтобы он сумел обработать новое состояние:
Контейнер 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);
Если при подключении этого компонента токен аутентификации уже существует, но в хранилище отсутствует currentUser, компонент вызовет конструктор действия currentUser, чтобы получить от back-end данные пользователя. Добавим его:
// 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')); }); }; }, // ... } // ...
Это прикроет нас, когда пользователь обновляет страницу браузера или снова переходит на корневой URL, не завершив предварительно свой сеанс. Следуя за уже сказанным, после аутентификации пользователя и передачи currentUser в состояние (state), данный компонент запустит обычную отрисовку, показывая компонент заголовка и собственные вложенные дочерние маршруты.
Компонент заголовка
Данный компонент отрисует граватар и имя пользователя вместе со ссылкой на доски и кнопкой выхода.
// 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> ); } }
При нажатии пользователем кнопки выхода происходит вызов метода singOut конструктора действия session. Добавим этот метод:
// 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); }); }; }, // ... } // ...
Он отправит на back-end запрос DELETE и, в случае успеха, удалит phoenixAuthToken из localStorage, а так же отправит действие USER_SIGNED_OUT, обнуляющее currentUser в состоянии (state), используя ранее описанный преобразователь сессии:
// 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; // ... } }
Ещё кое-что
Хотя мы закончили с процессом аутентификации и входа пользователя в приложение, мы ещё не реализовали ключевую функциональность, которая станет основой всех будущих возможностей, которые мы запрограммируем: пользовательские сокеты и каналы (the user sockets and channels). Этот момент настолько важен, что я скорее предпочёл бы оставить его для следующей части, где мы увидим, как выглядит userSocket, и как к нему подключиться, чтобы у нас появились двунаправленные каналы между front-end и back-end, показывающие изменения в реальном времени.
Сокеты и каналы
В предыдущей части мы завершили процесс аутентификации и теперь готовы начать веселье. С этого момента для соединения front-end и back-end мы будем во многом полагаться на возможности Phoenix по работе в реальном времени. Пользователи получат уведомления о любых событиях, затрагивающих их доски, а изменения будут автоматически показаны на экране.
Мы можем представить каналы (channels) в целом как контроллеры. Но в отличие от обработки запроса и возврата результата в одном соединении, они обрабатывают двунаправленные события на заданную тему, которые могут передаваться нескольким подключённым получателям. Для их настройки Phoenix использует обработчики сокетов (socket handlers), которые аутентифицируют и идентифицируют соединение с сокетом, а также описывают маршруты каналов, определяющие, какой канал обрабатывает соответствующий запрос.
Пользовательский сокет (user socket)
При создании нового приложения Phoenix оно автоматически создаёт для нас начальную конфигурацию сокета:
# lib/phoenix_trello/endpoint.ex defmodule PhoenixTrello.Endpoint do use Phoenix.Endpoint, otp_app: :phoenix_trello socket "/socket", PhoenixTrello.UserSocket # ... end
Создаётся и UserSocket, но нам понадобится внести некоторые изменения в нём, чтобы обрабатывать нужные сообщения:
# 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
По сути, у нас будет два разных канала:
UserChannelбудет обрабатывать сообщения на любую тему, начинающуюся с `"users:", и мы воспользуемся им, чтобы информировать пользователей о событиях, относящихся к ним самим, например, если они были приглашены присоединиться к доске.BoardChannelбудет обладать основной функциональностью, обрабатывая сообщения для управления досками, списками и карточками, информируя любого пользователя, просматривающего доску непосредственно в данный момент о любых изменениях.
Нам так же нужно реализовать функции connect и id, которые будут выглядеть так:
# 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
При вызове функции connect (что происходит автоматически при подключении к сокету — прим. переводчика) с token в качестве параметра, она проверит токен, получит из токена данные пользователя с помощью GuardianSerializer, созданного нами в части 3, и сохранит эти данные в сокете, так, что они в случае необходимости будут доступны в канале. Более того, она так же запретит подключение к сокету неаутентифицированных пользователей.
Обратите внимание, приведено два описания функции connect: def connect(%{"token" => token}, socket) do ... end и def connect(_params, _socket), do: :error. Благодаря механизму сопоставления с шаблоном (pattern matching) первый вариант будет вызван при наличии в ассоциативном массиве, передаваемом первым параметром, ключа "token" (а значение, связанное с этим ключом, попадёт в переменную, названную token), а второй — в любых других случаях. Функция connect вызывается фреймворком автоматически при соединении с сокетом.
Функция id используется для идентификации текущего подключения к сокету и может использоваться, к примеру, для завершения всех активных каналов и сокетов для данного пользователя. При желании это можно сделать из любой части приложения, отправив сообщение "disconnect" вызовом PhoenixTrello.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})
Кстати, с помощью <AppName>.Endpoint.broadcast(topic, message, payload) можно отправить сообщение не только об отключении пользователя, но и вообще любое сообщение всем пользователям, подписанным на соответствующую тему. При этом topic — это строка с темой, (например, "boards:877"), message — это строка с сообщением (например, "boards:update"), а payload — ассоциативный массив с данными, который перед отправкой будет преобразован в json. Например, вы можете отправить пользователям, которые находятся online, какие-то изменения, произведённые с помощью REST api, прямо из контроллера или из любого другого процесса.
Канал user
После того, как мы настроили сокет, давайте переместимся к UserChannel, который очень прост:
# 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
Этот канал позволит нам передавать любое сообщение, связанное с пользователем, откуда угодно, обрабатывая его на front-end. В нашем конкретном случае мы воспользуемся им для передачи данных о доске, на которую пользователь был добавлен в качестве участника, чтобы мы могли поместить эту новую доску в список данного пользователя. Мы также можем использовать канал для показа уведомлений о других досках, которыми владеет пользователь и для чего угодно другого, что взбредёт вам в голову.
Подключение к сокету и каналу
Прежде, чем продолжить, вспомним, что мы сделали в предыдущей части… после аутентификации пользователя вне зависимости от того, использовалась ли форма для входа или ранее сохранённый phoenixAuthToken, нам необходимо получить данные currentUser, чтобы переправить их в хранилище (store) Redux и иметь возможность показать в заголовке аватар и имя пользователя. Это выглядит неплохим местом, чтобы подключиться также к сокету и каналу, поэтому давайте проведём некоторый рефакторинг:
// 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, }); }); }; // ...
После переадресации данных пользователя мы создаём новый объект Socket из JavaScript-библиотеки Phoenix, передав параметром phoenixAuthToken, требуемый для установки соединения, а затем вызываем функцию connect. Мы продолжаем созданием нового канала пользователя (user channel) и присоединяемся к нему. Получив сообщение ok в ответ на join, мы направляем действие SOCKET_CONNECTED, чтобы сохранить и сокет, и канал в хранилище:
// 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; } }
Основная причина хранить эти объекты заключается в том, что они понадобятся нам во многих местах, так что хранение в состоянии (state) делает их доступными компонентам через свойства (props).
После аутентификации пользователя, подключения к сокету и присоединения к каналу, AuthenticatedContainer отрисует представление HomeIndexView, где мы покажем все доски, принадлежащие пользователю, равно как и те, куда он был приглашён в качестве участника. В следующей части мы раскроем, как создать новую доску и пригласить существующих пользователей, используя каналы для передачи результирующих данных вовлечёнными пользователям.
А пока не забудьте взглянуть на живое демо и исходный код конечного результата.
