Клон Trello на Phoenix и React. Части 4-5

в 8:27, , рубрики: Elixir, elixir-lang, Erlang/OTP, phoenix framework, React, redux, ruby on rails, Trello, отсебятина, перевод, Программирование, Разработка веб-сайтов, функциональное программирование

Клон Trello на Phoenix и React. Части 4-5 - 1

Оглавление (текущий материал выделен)

  1. Введение и выбор стека технологий
  2. Начальная настройка проекта Phoenix Framework
  3. Модель User и JWT-аутентификация
  4. Front-end для регистрации на React и Redux
  5. Начальное заполнение базы данных и контроллер для входа в приложение
  6. Аутентификация на front-end на React и Redux
  7. Настраиваем сокеты и каналы
  8. Выводим список и создаём новые доски
  9. Добавляем новых пользователей досок
  10. Отслеживаем подключённых пользователей досок
  11. Добавляем списки и карточки
  12. Выкладываем проект на Heroku

Front-end для регистрации на React и Redux

Оригинал

Предыдущую публикацию мы закончили созданием модели User с проверкой корректности и необходимыми для генерации зашифрованного пароля трансформациями набора изменений (changeset); так же мы обновили файл маршрутизатора и создали контроллер RegistrationController, который обрабатывает запрос на создание нового пользователя и возвращает данные пользователя и его jwt-токен для аутентификации будущих запросов в формате JSON. Теперь двинемся дальше — к front-end.

Подготовка маршрутизатора React

Основная цель — иметь два публичных маршрута, /sign_in и /sign_up, по которым сможет пройти любой посетитель, чтобы, соответственно, войти в приложение или зарегистрировать новый аккаунт.

Помимо этого нам понадобится / как корневой маршрут, чтобы показать все доски, относящиеся к пользователю, и, наконец, маршрут /board/:id для вывода содержимого выбранной пользователем доски. Для доступа к последним двум маршрутам пользователь должен быть аутентифицирован, в противном случае мы перенаправим его на экран регистрации.

Обновим файл routes для react-router, чтобы отразить то, что мы хотим сделать:

// 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';

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>
  </Route>
);

Хитрый момент — AuthenticatedContainer, давайте взглянем на него:

// web/static/js/containers/authenticated.js

import React        from 'react';
import { connect }  from 'react-redux';
import { routeActions } from 'redux-simple-router';

class AuthenticatedContainer extends React.Component {
  componentDidMount() {
    const { dispatch, currentUser } = this.props;

    if (localStorage.getItem('phoenixAuthToken')) {
      dispatch(Actions.currentUser());
    } else {
      dispatch(routeActions.push('/sign_up'));
    }
  }

  render() {
    // ...
  }
}

const mapStateToProps = (state) => ({
  currentUser: state.session.currentUser,
});

export default connect(mapStateToProps)(AuthenticatedContainer);

Вкратце, что мы тут делаем: проверяем при подключении компонента, присутствует ли jwt-токен в локальном хранилище браузера. Позже мы разберёмся, как этот токен сохранить, но пока давайте представим, что токен не существует; в результате благодаря библиотеке redux-simple-route перенаправим пользователя на страницу регистрации.

Компонент представления (view component) для регистрации

Это то, что мы будем показывать пользователю, если обнаружим, что он не аутентифицирован:

// web/static/js/views/registrations/new.js

import React, {PropTypes}   from 'react';
import { connect }          from 'react-redux';
import { Link }             from 'react-router';

import { setDocumentTitle, renderErrorsFor } from '../../utils';
import Actions              from '../../actions/registrations';

class RegistrationsNew extends React.Component {
  componentDidMount() {
    setDocumentTitle('Sign up');
  }

  _handleSubmit(e) {
    e.preventDefault();

    const { dispatch } = this.props;

    const data = {
      first_name: this.refs.firstName.value,
      last_name: this.refs.lastName.value,
      email: this.refs.email.value,
      password: this.refs.password.value,
      password_confirmation: this.refs.passwordConfirmation.value,
    };

    dispatch(Actions.signUp(data));
  }

  render() {
    const { errors } = this.props;

    return (
      <div className="view-container registrations new">
        <main>
          <header>
            <div className="logo" />
          </header>
          <form onSubmit={::this._handleSubmit}>
            <div className="field">
              <input ref="firstName" type="text" placeholder="First name" required={true} />
              {renderErrorsFor(errors, 'first_name')}
            </div>
            <div className="field">
              <input ref="lastName" type="text" placeholder="Last name" required={true} />
              {renderErrorsFor(errors, 'last_name')}
            </div>
            <div className="field">
              <input ref="email" type="email" placeholder="Email" required={true} />
              {renderErrorsFor(errors, 'email')}
            </div>
            <div className="field">
              <input ref="password" type="password" placeholder="Password" required={true} />
              {renderErrorsFor(errors, 'password')}
            </div>
            <div className="field">
              <input ref="passwordConfirmation" type="password" placeholder="Confirm password" required={true} />
              {renderErrorsFor(errors, 'password_confirmation')}
            </div>
            <button type="submit">Sign up</button>
          </form>
          <Link to="/sign_in">Sign in</Link>
        </main>
      </div>
    );
  }
}

const mapStateToProps = (state) => ({
  errors: state.registration.errors,
});

export default connect(mapStateToProps)(RegistrationsNew);

Не особо много можно рассказать об этом компоненте… он изменяет заголовок документа при подключении, выводит форму регистрации и перенаправляет результат конструктора действия (action creator) регистрации singUp.

Конструктор действия (action creator)

Когда предыдущая форма отправлена, нам нужно переслать данные на сервер, где они будут обработаны:

// web/static/js/actions/registrations.js

import { pushPath }  from 'redux-simple-router';
import Constants     from '../constants';
import { httpPost }  from '../utils';

const Actions = {};

Actions.signUp = (data) => {
  return dispatch => {
    httpPost('/api/v1/registrations', {user: data})
    .then((data) => {
      localStorage.setItem('phoenixAuthToken', data.jwt);

      dispatch({
        type: Constants.CURRENT_USER,
        currentUser: data.user,
      });

      dispatch(pushPath('/'));
    })
    .catch((error) => {
      error.response.json()
      .then((errorJSON) => {
        dispatch({
          type: Constants.REGISTRATIONS_ERROR,
          errors: errorJSON.errors,
        });
      });
    });
  };
};

export default Actions;

Когда компонент RegistrationsNew вызывает конструктор действия, передавая ему данные формы, на сервер отправляется новый POST-запрос. Запрос фильтруется маршрутизатором Phoenix и обрабатывается контроллером RegistrationController, который мы создали в предыдущей публикации. В случае успеха полученный с сервера jwt-токен сохраняется в localStorage, данные созданного пользователя передаются действию CURRENT_USER и, наконец, пользователь переадресуется на корневой путь. Наоборот, если присутствуют любые ошибки, связанные с регистрационными данными, будет вызвано действие REGISTRATIONS_ERROR с ошибками в параметрах, так что мы сможем показать их пользователю в форме.

Для работы с http-запросами мы собираемся положиться на пакет isomorphic-fetch, вызываемый из вспомогательного файла, который для этих целей включает несколько методов:

// web/static/js/utils/index.js

import React        from 'react';
import fetch        from 'isomorphic-fetch';
import { polyfill } from 'es6-promise';

export function checkStatus(response) {
  if (response.status >= 200 && response.status < 300) {
    return response;
  } else {
    var error = new Error(response.statusText);
    error.response = response;
    throw error;
  }
}

export function parseJSON(response) {
  return response.json();
}

export function httpPost(url, data) {
  const headers = {
    Authorization: localStorage.getItem('phoenixAuthToken'),
    Accept: 'application/json',
    'Content-Type': 'application/json',
  }

  const body = JSON.stringify(data);

  return fetch(url, {
    method: 'post',
    headers: headers,
    body: body,
  })
  .then(checkStatus)
  .then(parseJSON);
}

// ...

Преобразователи (reducers)

Последний шаг — обработка этих результатов действий с помощью преобразователей, в результате чего мы сможем создать новое дерево состояния, требуемое нашему приложению. Во-первых, взглянем на преобразователь session, в котором будет сохраняться currentUser:

// web/static/js/reducers/session.js

import Constants from '../constants';

const initialState = {
  currentUser: null,
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case Constants.CURRENT_USER:
      return { ...state, currentUser: action.currentUser };

    default:
      return state;
  }
}

В случае наличия ошибок регистрации любого типа необходимо добавить их к новому состоянию, чтобы мы могли показать их пользователю. Добавим их к преобразователю registration:

// web/static/js/reducers/registration.js

import Constants from '../constants';

const initialState = {
  errors: null,
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case Constants.REGISTRATIONS_ERROR:
      return {...state, errors: action.errors};

    default:
      return state;
  }
}

Обратите внимание, что для вывода ошибок мы обращаемся к фунцкии renderErrorsFor из этого вспомогательного файла:

// web/static/js/utils/index.js

// ...

export function renderErrorsFor(errors, ref) {
  if (!errors) return false;

  return errors.map((error, i) => {
    if (error[ref]) {
      return (
        <div key={i} className="error">
          {error[ref]}
        </div>
      );
    }
  });
}

В целом это всё, что нужно для процесса регистрации. Далее мы увидим, как существующий пользователь может аутентифицироваться в приложении и получить доступ к собственному содержимому.

Начальное заполнение базы данных и контроллер для входа в приложение

Оригинал

Вход пользователя в приложение

Ранее мы подготовили всё для того, чтобы посетители могли регистрироваться и создавать новые пользовательские аккаунты. В этой части мы собираемся реализовать функциональность, необходимую, чтобы позволить посетителям аутентифицироваться в приложение, используя e-mail и пароль. В конце мы создадим механизм для получения пользовательских данных с помощью их токенов аутентификации.

Начальное заполнение базы данных

Если у вас есть опыт работы с Rails, вы увидите, что первоначальное заполнение базы данных в Phoenix выглядит очень похоже. Всё, что нам нужно для этого — наличие файла seeds.exs:

# priv/repo/seeds.exs

alias PhoenixTrello.{Repo, User}

[
  %{
    first_name: "John",
    last_name: "Doe",
    email: "john@phoenix-trello.com",
    password: "12345678"
  },
]
|> Enum.map(&User.changeset(%User{}, &1))
|> Enum.each(&Repo.insert!(&1))

По сути, в этом файле мы просто добавляем в базу данных все данные, которые хотели бы предоставить нашему приложению в качестве начальных. Если вы хотите зарегистрировать любого другого пользователя — просто добавьте его в список и запустите заполнение базы:

$ mix run priv/repo/seeds.exs

Контроллер для входа в приложение

До того, как создать контроллер, необходимо внести некоторые изменения в файл router.ex:

# web/router.ex

defmodule PhoenixTrello.Router do
  use PhoenixTrello.Web, :router

  #...

  pipeline :api do
    # ...

    plug Guardian.Plug.VerifyHeader
    plug Guardian.Plug.LoadResource
  end

  scope "/api", PhoenixTrello do
    pipe_through :api

    scope "/v1" do
      # ...

      post "/sessions", SessionController, :create
      delete "/sessions", SessionController, :delete

      # ...
    end
  end

  #...
end

Первая добавка, которую нужно произвести — добавить в цепочку :api две вставки (plugs, далее будет оригинальный термин использоваться — plug, — поскольку слово "вставка" хоть и отражает букву сути, но не передаёт, как мне кажется, полного смысла; но если я не прав, буду рад нормальному русскому термину. Также имеет смысл для понимания почитать переводной материал о plug и plug pipeline — прим. переводчика):

  • VerifyHeader: этот plug просто проверяет наличие токена в заголовке Authorization (на самом деле, он помимо этого пытается расшифровать его, попутно проверяя на корректность, и создаёт структуру с содержимым токена — прим. переводчика)
  • LoadResource: если токен присутствует, то делает текущий ресурс (в данном случае — конретную запись из модели User — прим. переводчика) доступным как результат вызова Guardian.Plug.current_resource(conn)

Также нужно добавить в область /api/v1 ещё два маршрута для создания и удаления сессии пользователя, оба обрабатываемые контроллером SessionController. Начнём с обработчика :create:

# web/controllers/api/v1/session_controller.ex

defmodule PhoenixTrello.SessionController do
  use PhoenixTrello.Web, :controller

  plug :scrub_params, "session" when action in [:create]

  def create(conn, %{"session" => session_params}) do
    case PhoenixTrello.Session.authenticate(session_params) do
      {:ok, user} ->
        {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token)

        conn
        |> put_status(:created)
        |> render("show.json", jwt: jwt, user: user)

      :error ->
        conn
        |> put_status(:unprocessable_entity)
        |> render("error.json")
    end
  end

  # ...
end

Чтобы аутентифицировать пользователя с полученными параметрами, мы воспользуемся вспомогательным модулем PhoenixTrello.Session. Если всё :ok, то мы зашифруем идентификатор пользователя и впустим его (encode and sign in — несколько вольный, но более понятный перевод — прим. переводчика). Это даст нам jwt-токен, который мы сможем вернуть вместе с записью user в виде JSON. Прежде, чем продолжить, давайте взглянем на вспомогательный модуль Session:

# web/helpers/session.ex

defmodule PhoenixTrello.Session do
  alias PhoenixTrello.{Repo, User}

  def authenticate(%{"email" => email, "password" => password}) do
    user = Repo.get_by(User, email: String.downcase(email))

    case check_password(user, password) do
      true -> {:ok, user}
      _ -> :error
    end
  end

  defp check_password(user, password) do
    case user do
      nil -> false
      _ -> Comeonin.Bcrypt.checkpw(password, user.encrypted_password)
    end
  end
end

Он пытается найти пользователя по e-mail и проверяет, соответствует ли пришедший пароль зашифрованному паролю пользователя. Если пользователь существует и пароль правильный, возвращается кортеж, содержащий {:ok, user}. В противном случае, если пользователь не найден или пароль неверен, возвращается атом :error.

Возвращаясь к контроллеру SessionController обратите внимание, что он интерпретирует шаблон error.json, если результат аутентификации пользователя — упомянутый ранее атом :error. Наконец, необходимо создать модуль SessionView для отображения обоих результатов:

# web/views/session_view.ex

defmodule PhoenixTrello.SessionView do
  use PhoenixTrello.Web, :view

  def render("show.json", %{jwt: jwt, user: user}) do
    %{
      jwt: jwt,
      user: user
    }
  end

  def render("error.json", _) do
    %{error: "Invalid email or password"}
  end
end

Пользователи, уже авторизовавшиеся в приложении

Другая причина возвращать представление пользователя в JSON при аутентификации в приложении заключается в том, что эти данные могут нам понадобиться для разных целей; к примеру, чтобы показать имя пользователя в шапке приложения. Это соответствует тому, что мы уже сделали. Но что, если пользователь обновит страницу браузера, находясь на первом экране? Всё просто: состояние приложение, управляемое Redux, будет обнулено, а полученная ранее информация исчезнет, что может привести к нежелательным ошибкам. А это не то, чего мы хотим, так что для предотвращения такой ситуации мы можем создать новый контроллер, отвечающий за возврат при необходимости данных аутентифицированного пользователя.

Добавим в файл router.ex новый маршрут:

# web/router.ex

defmodule PhoenixTrello.Router do
  use PhoenixTrello.Web, :router

  #...

  scope "/api", PhoenixTrello do
    pipe_through :api

    scope "/v1" do
      # ...

      get "/current_user", CurrentUserController, :show

      # ...
    end
  end

  #...
end

Теперь нам нужен контроллер CurrentUserController, который выглядит так:

# web/controllers/api/v1/current_user_controller.ex

defmodule PhoenixTrello.CurrentUserController do
  use PhoenixTrello.Web, :controller

  plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController

  def show(conn, _) do
    user = Guardian.Plug.current_resource(conn)

    conn
    |> put_status(:ok)
    |> render("show.json", user: user)
  end
end

Guardian.Plug.EnsureAuthenticated проверяет наличие ранее проверенного токена, и при его отсутствии перенаправляет запрос на функцию :unauthenticated контроллера SessionController. Таким способом мы защитим приватные контроллеры, так что если появится желание определённые маршруты сделать доступными только аутентифицированным пользователям, всё, что понадобится — добавить этот plug в соответствующие контроллеры. Прочая функциональность довольно проста: после подтверждения наличия аутентифицированного токена будет транслирован current_resource, которым в нашем случае являются данные пользователя.

Наконец, нужно в контроллер SessionController добавить обработчик unauthenticated:

# web/controllers/api/v1/session_controller.ex

defmodule PhoenixTrello.SessionController do
  use PhoenixTrello.Web, :controller

  # ...

  def unauthenticated(conn, _params) do
    conn
    |> put_status(:forbidden)
    |> render(PhoenixTrello.SessionView, "forbidden.json", error: "Not Authenticated")
  end
end

Он вернёт код 403 — Forbidden вместе с простым текстовым описанием ошибки в JSON. На этом мы закончили с функциональность back-end, относящейся ко входу в приложение и последующей аутентификации. В следующей публикации мы раскроем, как справиться с этим во front-end и как подключиться к UserSocket, сердцу всех вкусняшек режима реального времени. А пока не забудьте взглянуть на живое демо и исходный код конечного результата.

Автор: heathen

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js