На проекте необходимо было сделать логин через модальные окна и «обычные» страницы для разных типов устройств. После поиска понял, что зачастую описывается не совсем то, что нужно. Так здесь просто помещают форму в модальное окно (фактически пользуясь страницей из wiki devise), а тут (вход и регистрация) переопределяют методы в контроллерах devise так, что они постоянно отдают только json и для «немодального» поведения нужно будет писать много условий с проверкой формата запроса. Поэтому я решил поэкспериментировать в новом приложении и написать поддержку 2 форматов с минимальным количеством переопределения и грязных хаков.
Создание приложения
- Генерим приложение без тестов и запуска bundle install:
rails new devise_modal -B -T
- Добавляем нужные гемы в Gemfile:
gem 'therubyracer', platforms: :ruby
gem "less-rails"
gem 'twitter-bootstrap-rails', branch: 'bootstrap3'
— для модальных окон используем bootstrapgem 'devise'
авторизация будет через devise
И устанавливаем всё:
bundle install
- Запускаем нужные генераторы
rails g bootstrap:install static
, «static» так как ничего менять в стилях bootstrap'а не будем
rails g devise:install; rails g devise User; rake db:migrate
— устанавливаем devise и создаём пользователя - Создаём контроллер, который будет отображать главную страницу:
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