- Введение и выбор стека технологий
- Начальная настройка проекта 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
, где мы покажем все доски, принадлежащие пользователю, равно как и те, куда он был приглашён в качестве участника. В следующей части мы раскроем, как создать новую доску и пригласить существующих пользователей, используя каналы для передачи результирующих данных вовлечёнными пользователям.
А пока не забудьте взглянуть на живое демо и исходный код конечного результата.
Автор: heathen