views:

110

answers:

2

One thing you can do on my rap lyric explanation site is "like" explanations (once you're logged in):

I'd like to show the "Like" links to users who aren't logged in, and then, when a non-logged in user clicks "Like", show him a lightbox with a "Login or Register" form (like Digg / Reddit)

What's the best way to accomplish this?

Currently I'm using this approach:

  1. Clicking "Like" POSTs to /annotations/:id/vote (the POST body indicates whether the user is liking or "unliking").
  2. The vote Annotation controller action has a require_user before_filter that looks like this:

    def require_user
      unless current_user
        store_desired_location
        flash[:notice] = "You'll need to login or register to do that"
        redirect_to login_path # map.login '/login', :controller => 'user_sessions', :action => 'new'
        return false
      end
    end
    
  3. user_sessions#new looks like this:

    def new
      @user_session = UserSession.new
      respond_to do |format|
        format.html {}
        format.js {
          render :layout => false
        }
      end
    end
    

The problem is that the redirect doesn't seem to work correctly over javascript:

How do I get this to redirect correctly?

Also, is this the right general approach? Another thought I had was to attach a different handler to the "Like" links in javascript when there was no logged in user (but I don't think this method scales well to other actions that I'd like to handle the same way)

A: 

I would approach this in the way you describe at the bottom of your question. Before displaying the page initially, check if the user is logged in. If they are, the "Like" links should use their normal behavior. If not, bind a click event to show the register/login panel. There's nothing about this that can't be reused. In fact, we use this exact method at my job. Any user action that requires authentication either follows its normal behavior or pops up a generic login panel depending on login state at the time the page loads.

Jimmy Cuadra
"If not, bind a click event to show the register/login panel" -- I just don't want to have to do this for every action that requires users to be logged in (also, when the user DOES log in, how do I make sure whatever action he originally requested takes place?)
Horace Loeb
+2  A: 

There's a few problems to overcome here.

  1. Browsers in general do not allow redirecting to a POST request.

  2. redirect_to doesn't preserve format without additional input.

  3. Store location does not preserve form data.

All these problems can be solved by eliminating redirects.

Here is how I've handed it in the past:

Instead of redirecting in required_user, render. If a before filter redirects or renders the pending action is cancelled. (No need to return false either). Unfortunately going this route blurs controller boundaries. But allows for simple html fallback, and lends its self to DRYness.

The high level view of the new work flow will be:

  1. Request to annotations#vote (POST)
  2. required_user filter fails
  3. render new session
  4. submit login information and original POST data back to annotations#vote (POST)
  5. new filter in vote captures session information and logs in. vote proceeds as expected. If login fails return to 3.
  6. annotations#vote redirects/renders as it should

Start by reworking the require_user to render the user_sessions#new template.

def require_user
  unless current_user
    flash[:notice] = "You'll need to login or register to do that"
    @user_session ||= UserSession.new
    respond_to do |format|
      format.html {render :template => 'user_sessions/new'}
      format.js {
        render :template => 'user_sessions/new', :layout => false
      }
    end
  end
end

The @user_session ||= UserSession.new ensures we can return validation errors to the form.

Now we've got to beef up your user_session#new template so that it can remember the action. Also if you plan on using lightboxes, this should be a partial rendered rendered by relevant RJS or the new.html.erb.

First we create a partial to create hidden fields preserving the POST data that would have been lost in a redirect:

<% if params[:controller] == "annotations" %>
  <% content_for :old_form do %>
    <%= hidden_field_tag "annotation[song_id]", params[:annotation][:song_id] %>
    <%= hidden_field_tag "annotation[vote]", params[:annotation][:vote] %>
  <% end %>
<% end %>

Then render that partial in the login partial that will occupy your lightbox:

<%= render :partial => vote_form_replica %>

<% url = params[:controller] == "user_sessions ? user_sessions_url : {} %>
<% form_tag @user_session, :url => url do |f| %>
  <%= yield :old_form %>
  <%= 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 at this point should be annotations/:id/vote

Now for the new filter to login. Essentially it will be doing what ever UserSessionsController#create does without the render/redirect. The following is copied from the RESTful authentication plugin.

def authenticate
  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
  end
end

All that's left is to make sure the filter order is right.

before_filter :authenticate, :require_user, :only => :vote

N.B.: You're probably not going to use this version of require_user without this version of authenticate so it makes sense to combine them into a single filter.

And that's it. The way this has been set up allows for robust DRY easily reuseable code. By placing the new filters into ApplicationController they're available in any controller. From this point, adding this functionality to any other controllers/actions takes only 3 simple steps:

  1. Create a new partial modelled after the vote_form_replica partial.
  2. Add the corresponding render statement to the new session template.
  3. Apply the filters to your actions.
EmFi
I think you have to combine the `:authenticate` and `:require_user` before filters. As is, you cannot call `:require_user` separately because the user will never be able to log in. However, I do think this is the right approach on the whole -- in particular the `content_for` trick for "replaying" the user's desired action once he logs in and the general idea of logging the user in via a `before_filter` are both quite clever
Horace Loeb
Thanks. the content_for isn't really necessary, I just felt it made the code easier to read. It works better in you partials, so that adapting this for other controllers/actions requires a new partial and corresponding render :partial statement. I've updated the solution to reflect that. Combining the authenticate and require_user filters into a single filter is more practical. I felt the explanation of the solution just flowed better by breaking it into atomic steps you can take to adapt your current code to this scheme.
EmFi
Oh, also -- shouldn't `render :action => 'user_sessions/new'` be `render :template => 'user_sessions/new'` (in `require_user`)?
Horace Loeb
probably. I just took that line from your posted code.
EmFi
Ah, I've just run into a problem with this approach: 1) Someone tries to GET /songs/bulk_edit (requires login) 2) Site renders login form (path is still /songs/bulk_edit) 3) User tries to log in, POSTing to /songs/bulk_edit 4) Expecting a GET (not a POST) to /songs/bulk_edit, my application barfs. Thoughts?
Horace Loeb
That's something I over looked, and where the solution deviates from the standard REST patterns. You can solve the problem by adding an additional route. `map resources :songs, {:bulk_edit => [:post, :get]}`
EmFi