Devise: вход и регистрация в модальных окнах

в 9:22, , рубрики: Без рубрики

На проекте необходимо было сделать логин через модальные окна и «обычные» страницы для разных типов устройств. После поиска понял, что зачастую описывается не совсем то, что нужно. Так здесь просто помещают форму в модальное окно (фактически пользуясь страницей из wiki devise), а тут (вход и регистрация) переопределяют методы в контроллерах devise так, что они постоянно отдают только json и для «немодального» поведения нужно будет писать много условий с проверкой формата запроса. Поэтому я решил поэкспериментировать в новом приложении и написать поддержку 2 форматов с минимальным количеством переопределения и грязных хаков.

Создание приложения

  1. Генерим приложение без тестов и запуска bundle install: rails new devise_modal -B -T
  2. Добавляем нужные гемы в Gemfile:
    • gem 'therubyracer', platforms: :ruby
      gem "less-rails"
      gem 'twitter-bootstrap-rails', branch: 'bootstrap3' — для модальных окон используем bootstrap
    • gem 'devise' авторизация будет через devise

    И устанавливаем всё: bundle install

  3. Запускаем нужные генераторы
    rails g bootstrap:install static, «static» так как ничего менять в стилях bootstrap'а не будем
    rails g devise:install; rails g devise User; rake db:migrate — устанавливаем devise и создаём пользователя
  4. Создаём контроллер, который будет отображать главную страницу:
    rails g controller welcome index --no-helper --no-assets
    В config/routes.rb привязываем index к главной странице:
    root 'welcome#index'

В конце этого этапа есть приложение, с формами входа/регистрации на стандартных ссылках для devise: users/sign_in и users/sign_up.

Модальные окна для форм

В формах нету ничего примечательного — используем стандартные devise'овские сделав их remote и поменяв формат на json. Дальше делаем их модальными, обернув в соответствующие классы bootstrap'а. В итоге получились такие partial'ы:
app/views/shared/_sign_in.html.erb

<div class="modal hide fade in" id="sign_in">
  
  <div class="modal-header">
    <button class="close" data-dismiss="modal">x</button>
    <h2>Sign in</h2>
  </div>

  <div class="modal-body">
    <%= form_for(resource, as: resource_name, url: session_path(resource_name), html:{id: 'sign_in_user', :'data-type' => 'json'}, remote: true) do |f| %>
      <div>
        <%= f.label :email %><br />
        <%= f.email_field :email, autofocus: true %>
      </div>

      <div>
        <%= f.label :password %><br />
        <%= f.password_field :password, autocomplete: "off" %>
      </div>

      <% if devise_mapping.rememberable? -%>
        <div>
          <%= f.check_box :remember_me %>
          <%= f.label :remember_me %>
        </div>
      <% end -%>

      <div>
        <%= f.submit "Sign in" %>
      </div>
    <% end %>
  </div>
  <div class="modal-footer">
  </div>
</div>

app/views/shared/_sign_up.html.erb

<div class="modal hide fade in" id="sign_up">
  
  <div class="modal-header">
    <button class="close" data-dismiss="modal">x</button>
    <h2>Sign up</h2>
  </div>

  <div class="modal-body">
    <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: {id: 'sign_up_user', :'data-type' => 'json'}, remote: true) do |f| %>
      <%= devise_error_messages! %>

      <div>
        <%= f.label :email %><br />
        <%= f.email_field :email, autofocus: true %>
      </div>

      <div>
        <%= f.label :password %><br />
        <%= f.password_field :password, autocomplete: "off" %>
      </div>

      <div>
        <%= f.label :password_confirmation %><br />
        <%= f.password_field :password_confirmation, autocomplete: "off" %>
      </div>

      <div><%= f.submit "Sign up" %></div>
    <% end %>
  </div>
  <div class="modal-footer">
  </div>
</div>

Добавим отображение этих файлов и ссылок для их вызова в layout:

<%= link_to "Sign in", "#sign_in", "data-toggle" => "modal", :class => 'btn btn-small' %>
<%= link_to "Sign up", "#sign_up", "data-toggle" => "modal", :class => 'btn btn-small' %>
<%= render 'shared/sign_in' %>
<%= render 'shared/sign_up' %>

А после этого облагородим немного, сделав проверку на наличие юзера:
app/views/layouts/application.html.erb

<% if current_user %>
  <%= "Hello, #{current_user.email}" %>
  <%= link_to "Sign out", destroy_user_session_path, :method => :delete %>
<% else %>
  <%= link_to "Sign in", "#sign_in", "data-toggle" => "modal", :class => 'btn btn-small' %>
  <%= link_to "Sign up", "#sign_up", "data-toggle" => "modal", :class => 'btn btn-small' %>
  <%= render 'shared/sign_in' %>
  <%= render 'shared/sign_up' %>
<% end %>

Чтобы всё это работало нужно добавить несколько методов в application_helper, которые определяют resource и связанные с ним вещи для данного контекста:
app/helpers/application_helper.rb

def resource_name
  :user
end

def resource
  @resource ||= User.new
end

def devise_mapping
  @devise_mapping ||= Devise.mappings[:user]
end

Теперь есть модальные формы, которые доступны с любой страницы и позволяют входить и регистрироваться. Осталось только сделать, чтобы devise на эти запросы в ответ не отправлял html страницы.

JSON ответы от devise

В геме devise за ошибки связанные со входом отвечает FailureApp. При возникновении ошибки в SessionsController'е, который отрабатывает запросы на вход, вызывается respond, где с помощью http_auth? проверяется: нужно слать 401 статус или же переадресовывать на другую страницу. Так как по умолчанию у devise'а:
config/initializers/devise.rb

config.http_authenticatable_on_xhr = true

то и возвращается 401.
RegistrationsController же в ответ на AJAX запрос присылает html страницу, чтобы это исправить переопределим его немного — укажем явно, какие форматы нас интересуют:
rails g controller Registrations --no-helper --no-assets --no-views
config/routes.rb

devise_for :users, controllers: {registrations: 'registrations'}

app/controllers/registrations_controller.rb

class RegistrationsController < Devise::RegistrationsController
  respond_to :html, :json
end

Теперь при неудачной попытке регистрации будет отдаваться 422 статус с текстами ошибок в responseJSON['errors'], а при удачной — 201. Аналогично для SessionsController'а при удачном входе нужно отдавать статус, а не html-страницу, поэтому «научим» и его правильно реагировать на json запросы:
rails g controller Sessions --no-helper --no-assets --no-views
config/routes.rb

devise_for :users, controllers: {sessions: 'sessions', registrations: 'registrations'}

app/controllers/sessions_controller.rb

class SessionsController < Devise::SessionsController
  respond_to :html, :json
end

Также можно написать javascript, который будет обрабатывать ответы от модальных форм, например такой:
app/assets/javascripts/welcome.js.coffee

$ ->
  $("form#sign_in_user, form#sign_up_user").bind("ajax:success", (event, xhr, settings) ->
    $(this).parents('.modal').modal('hide')
  ).bind("ajax:error", (event, xhr, settings, exceptions) ->
    error_messages = if xhr.responseJSON['error']
      "<div class='alert alert-danger pull-left'>" + xhr.responseJSON['error'] + "</div>"
    else if xhr.responseJSON['errors']
      $.map(xhr.responseJSON["errors"], (v, k) ->
        "<div class='alert alert-danger pull-left'>" + k + " " + v + "</div>"
      ).join ""
    else
      "<div class='alert alert-danger pull-left'>Unknown error</div>"
    $(this).parents('.modal').children('.modal-footer').html(error_messages)
  )

При входе оборачиваем ошибку в alert, а при регистрации — ошибки по каждому параметру, после чего выводим полученное сообщение в footer'е. При успешном запросе просто убираем модальную форму (можно ещё обновлять блок в layout'е, в котором проверяется наличие пользователя, чтобы отображать данные пользователя (они также приходят в ответе)).
Теперь контроллеры отдают ответы в правильном формате, как и для модальных форм — json, так и для стандартных(users/sign_in, users/sign_up) — html. И всё, что понадобилось для этого понадобилось — переопределить контроллеры, расширив набор форматов:

respond_to :html, :json

Примечание

Приложение писалось на rails 4, но отличия для 3.2 будут минимальны: запустится bundle install при создании приложения, нужно будет удалить public/index.html а также путь на главную будет выглядеть чуть иначе:
config/routes.rb

root to: 'welcome#index'

Автор: vldvld

Источник

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


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