393: Guest User Record
(view original Railscast)
Let’s say that we have a to-do list application where the user can add tasks and mark them as complete. A task list is private for a user which means that a new user must first sign up for the app before they can try it out.
This is the page that a new user sees when they first visit the app with a description to try to convince them to try it. When they click the “Try it for free” button they see a sign-up form and at this point a lot of potential customers will leave as they won’t want to give their personal information just to try out an app. What can we do to improve this user experience? Instead of redirecting new users to a sign-up form we’ll create a temporary guest account that they can use to try the application. Later on they can sign up for a permanent account if they like it.
Handling Guest Users
The first question that comes to mind is should we store the guest user account in the database if it’s just temporary? Our application is currently structured so that a user has many tasks and we use this association in the TasksController
to ensure that only the current user’s tasks are shown. While we could keep track of a guest’s tasks’ ids in a session this could get messy as our application grows and we have other associations with out User record. This would also mean that we had a lot of user_id
foreign keys in our database that will be null and we’d have no way then of knowing if two tasks are shared by the same guest user and so on. Given all this we’ll keep track of all the guests in the database and to start we’ll add a boolean guest column to the users
table.
$ rails g migration add_guest_to_users guest:boolean $ rake db:migrate
Now we need to modify our application so that when a user clicks the “Try it for free” button a new guest user account is automatically created for them. The button currently redirects the user to the new_users_path
so that the form is displayed, but instead we’ll point it to the users_path
with a POST action.
/app/views/tasks/index.html.erb
<%= button_to "Try it for free!", users_path, method: :post %>
Clicking this button now triggers the UsersController
’s create
action. This is currently set up to accept parameters from a form and we’ll modify it so that if no parameters are passed in a new guest user is created, using a new new_guest
class method on the User
model.
/app/controllers/users_controller.rb
def create @user = params[:user] ? User.new(params[:user]) : User.new_guest if @user.save session[:user_id] = @user.id redirect_to root_url else render "new" end end
In our User model we’ve written the authentication from scratch as we showed in episode 250. We use has_secure_password
which Rails provides and which is a convenient way to add authentication to an application. If you’re using Devise or another authentication gem you’ll need to alter the technique we use here.
/app/models/user.rb
class User < ActiveRecord::Base has_many :tasks attr_accessible :username, :email, :password, :password_confirmation validates_presence_of :username, :email validates_uniqueness_of :username has_secure_password def self.new_guest new { |u| u.guest = true } end end
In the new_guest
method we create a new User
and set its guest
attribute to true
. We do this inside a block because the guest attribute is protected from mass-assignment.
A guest user will now be created whenever we click that button, but what if we want this to work a little differently? Maybe we don’t want to override the UsersController
’s create
method or we want a guest user to always be available if we try to access the current user when one isn’t logged in. In these cases we can override the current_user
method in the ApplicationController
and have it fall back to creating a guest user if there is no current user_id
session variable. We won’t take that approach here but it’s worth considering as a way to handle guest users.
Dealing With Validations
Let’s try our changes out. When we visit the home page then click the “Try it for free” button the UsersController
attempts to create a guest user but that user fails the validation and so we’re redirected to the sign-up form.
What we need is for the new_guest
method to return a valid user record and there are multiple ways that we can do this. One option is to add fake data for the required fields so that the validation passes. This is certainly an option but if we have different authentication techniques, such as signing in through Twitter it’s harder to fake this data. This approach also means that we’re filling up the database with data that will never be used. Instead, we’ll modify the validations so that they’re conditional and not required if the current user is a guest.
/app/models/user.rb
validates_presence_of :username, :email, unless: :guest? validates_uniqueness_of :username, allow_blank: true
When we click the button now the validations still fail as we haven’t supplied a password. This validation is added automatically by has_secure_password
. In Rails 4 we’ll be able to pass in a validations
option and set it to false
so that these validations are skipped and then set them manually. Unfortunately this isn’t available in Rails 3 but the implementation for has_secure_password
is surprisingly simple. The method is only around a dozen lines long and most of the logic is contained in a module called InstanceMethodsOnActivation
which we can easily include manually. We can mimic this functionality and alter it to suit our needs. Once we’ve done that out model class looks like this:
/app/models/user.rb
class User < ActiveRecord::Base has_many :tasks attr_accessible :username, :email, :password, :password_confirmation validates_presence_of :username, :email, :password_digest, unless: :guest? validates_uniqueness_of :username, allow_blank: true validates_confirmation_of :password # override has_secure_password to customize validation until Rails 4. require 'bcrypt' attr_reader :password include ActiveModel::SecurePassword::InstanceMethodsOnActivation def self.new_guest new { |u| u.guest = true } end end
Our class now mimics the functionality of has_secure_password
but without the validations. This means that we can manually add the validations we want instead. If you’re using Devise and want to do something similar to this you can remove the Validatable
module and add the validations manually or alternatively we can remove the email_changed?
and password_required?
methods so that they’re dependent on whether the current user is a guest.
We can try our application out now. If we click “Try it for free” now we’re taken to the home page and we can add items and mark them as complete without having created a user account.
There are still some minor issues with our application. For example the login status text says “Logged in as .”. We should instead give an indication that they’re logged in as a guest user and give them the chance to sign up for full membership. We can do this by editing the application’s layout file. This currently displays the current user’s username. We’ll display the name instead, which is a new method on the User model that we’ll write shortly.
/app/views/layouts/application.html.erb
<% if current_user %> Logged in as <%= current_user.name %>. <%= link_to "Log Out", logout_path %> <% else %> <%= link_to "Log In", login_path %> <% end %>
The new name method will check if the current user is a guest and, if so, display “Guest” instead of their username.
/app/models/user.rb
def name guest ? "Guest" : username end
Now we need a way for guests to sign up and we’ll do this by adding a link to the layout that only shows if they’re a guest.
/app/views/layouts/application.html.erb
<% if current_user %> Logged in as <%= current_user.name %>. <% if current.user.guest? %> <%= link_to "Become a member", signup_path %> <% else %> <%= link_to "Log Out", logout_path %> <% end %> <% else %> <%= link_to "Log In", login_path %> <% end %>
We could make a logout link for guests too but we’d need to be a little more careful about that as guests can’t log back in.
Storing Guest Users’ Tasks
The trickiest part of all this is that we need to persist a guest user’s tasks when they become a member. There are several different ways that we can do this. One option is to change the create
action so that it updates the current user record and removes the guest
flag instead of creating a new record. Trying to juggle both new and existing user records in the same action can get a little messy, however, so we won’t try this. Instead we’ll move the associated data from the current user to the newly-created user if a current user exists and they are a guest.
/app/controllers/users_controller.rb
def create @user = params[:user] ? User.new(params[:user]) : User.new_guest if @user.save current_user.move_to(@user) if current_user && current.user.guest? session[:user_id] = @user.id redirect_to root_url else render "new" end end
We call a move_to
method on the User
model to move the data while we’ll need to write. This will go through all the different associations and call update_all
on them setting the user_id
to the new user’s id.
/app/models/user.rb
def move_to(user) tasks.update_all(user_id: user.id) end
We could also destroy the guest user here but we need to be careful if we do this as it might still have the associations still tied to it. For example if a user has many tasks and dependent
is set to destroy
this will end up destroying the tasks it’s associated with even though we’ve updated them to the new user. A better solution is to create a Rake task to destroy old guest accounts. This could look like this:
/lib/tasks/guests.rake
namespace :guests do desc "Remove guest accounts more than a week old." task :cleanup => :environment do User.where(guest: :true).where("created_at < ?", 1.week.ago).destroy_all end end
The Rake task will go through all the User
records and destroy all the guest accounts that were created over a week ago. If we do this it would be a good idea to notify the guest users that their accounts will be deleted after a week unless they upgrade to a full account. To have old guest accounts deleted automatically we could set up this Rake task as a cron job using the Whenever gem as demonstrated in episode 164. Now when a guest user clicks “Become a member” and fills in the signup form all their tasks are automatically moved over to their new account.