A growing trend around the web are buttons and links that when clicked will either perform an action or prompt the user to login with a lightbox. Lightboxes are those frames that pop up in response to other actions. There are many Rails plugins that describe them in more detail, so I won’t be.
The generally accepted Rails way of doing this is with a before filter, such as the login_required filter supplied by many authentication plugins. However this approach causes a problem, once the user is logged in they are redirected to the last page they visited. Their action that triggered the login is ignored and they will have to do it again.
At the time of writing, this process is exemplified by Digg. Attempting to digg a link while logged out will prompt a login with a light box, upon submitting valid credentials the light box disappears and the user is logged in, but the digg is not counted.
This may be standard practice, but it makes for a poor user experience. Ideally, the login action should complete the interrupted action that triggered the login. In terms of the Digg example, I should automatically digg the link when I login, when I login by responding to a prompt that was triggered by an attempt to digg a link while logged out.
How about adding that ideal behaviour to your Ruby on Rails applications?
There are three problems with the standard Rails authentication process that need to be overcome before our applications can behave this way.
- Browsers in general do not allow redirecting to a POST request.
- redirect_to doesn’t preserve format without additional input.
- Store location does not preserve form data.
Conveniently, all three of these problems can be solved by eliminating redirects in the process of logging in as a response to a protected action.
With that in mind, we must devise a new combined authentication/action plan. The following is an example of the new login process triggered by an action requiring an authenticated user.
- AJAX request to a protected action (POST).
- required user filter triggers. If a user is logged in skip to step 6, otherwise proceed to step 3.
- render new session form containing hidden fields with post data required for the interrupted protected action.
- submit login information and original POST data back to the protected action (POST).
- required user captures session information and logs in. If login fails return to 3.
- protected action proceeds as expected.
Let’s start by defining the require_user filter that does the heavy lifting.
class ApplicationController < ActionController::Base def require_user unless logged_in? if params[:login].present? && params[:password].present? # attempt to log in self.current_user = User.authenticate(params[:login], params[:password]) if logged_in? if params[:remember_me] == "1" current_user.remember_me unless current_user.remember_token? cookies[:auth_token] = { :value => self.current_user.remember_token, :expires => self.current_user.remember_token_expires_at} end else # login failed flash[:error] = "Invalid username/password combination" end end end unless logged_in? flash[:notice] = "You'll need to login or register to do that" respond_to do |format| format.html {render :template => 'user_sessions/new'} format.js { render :template => 'user_sessions/new', :layout => false } end end end end
The next step is to beef up the user_sessions#new form to include the old post data. The following is a partial that should be rendered by user_sessions/new.html.erb, and used to populate the light box by user_session/new.js.rjs
<% unless params[:controller] == "user_sessions" %> <%= render :partial => "#{params[:controller]}/#{params[:action]}_form_replica" %> <% end %> <% url = params[:controller] == "user_sessions ? user_sessions_url : {} %> <% form_remote_tag @user_session, :url => url, :html => {:action => url} do |f| %> <%= yield :old_form unless params[:controller] == "user_sessions" %> <%= f.label :user_name %> <%= f.text_field :user_name %> <%= f.label :password %> <%= f.password_field :password %> <%= submit_tag %> <%end%>
The empty hash for url in the form_tag looks like an error, but isn’t. It ensures that the form data is posted to the url that rendered the form. Which we want to be the protected action unless the user is using the vanilla log in page.
There’s one last thing to do. Create the replica partial for our protected action form referenced in the user_sessions/new view:
The following should be stored at app/views/controller/_action_form_replica
<% content_for :old_form do %> <%= hidden_field_tag "model[:field_a]", params[:model][:field_a] %> <%= hidden_field_tag "model[:field_b]", params[:model][:field_b] %> ... <% end %>
All that’s left is to add the filter to the your controller:
class ExampleController < ApplicationController before_filter :require_user, :only => :protected_action def protected_action ... end end
To use this behaviour on other actions all, you need to do is add the filter to the controller, and create a new partial containing old form data.
Caveats:
- Your required_user method may look different depending on what authentication plugin you use
- The form replica partial must exist for any of these actions even if no form data needs to be preserved.
- This technique should not be used for GET requests. There are many reasons for this, plain text authentication information in the url come to mind.
N.B.: For Rails 3 users replace the form_remote_tag with form_tag and use the :remote => true option.
Comments