How To: Override confirmations so users can pick their own passwords as part of confirmation activation

Dan Jensen edited this page Sep 23, 2018 · 77 revisions

(For a simpler but less customizable approach, check https://github.com/plataformatec/devise/wiki/How-To:-Email-only-sign-up)

Some websites allow the creation of an account providing only a username/email, leaving out the password. The sign up step is thus reduced to the bare minimum. At sign up time, an activation/confirmation link is sent by e-mail to the newly registered user. Following the link leads to a page where the new user must pick a password to confirm the account.

Here's how to add this functionality to your website, by overriding Devise's ConfirmationsController.

As a service preview

The website may provide a few ways to confirm the account and, in the mean time, allow the "pending" user to use all of the website features, as a "service preview"; or it could limit the features scope. This could prove useful for SaaS, for instance. If the confirmation is not performed within a certain time range, the account is disabled somehow.

If you do want to allow for a "service preview", in config/initializers/devise.rb, set confirm_within (Devise < 2.0) or allow_unconfirmed_access_for (Devise 2.0+) config key to a value like 2.days or anything suitable for your requirements.

If you do not want to allow for a "service preview", make sure that confirm_within or allow_unconfirmed_access_for is set to 0 in config/initializers/devise.rb. This is to prevent people from signing in. If you manage several scopes with Devise, you may set confirm_within or allow_unconfirmed_access_for per model, as an option to the devise instruction. Thus you could require admins to confirm their account, but allows 2.days free-sign up for users.

For Rails 3/4 & Devise 3.1-3.5.1

To use Rails 3/4 and Devise 3.1-3.5.1, you must do the same as in older versions, but with an updated confirmations_controller, because Devise 3.1 introduced encrypted confirmation_tokens (and removed it again in Devise 3.5.2).

1) Change render_with_scope method to render on ConfirmationsController

Devise::Controllers::ScopedViews::render_with_scope was removed in version 2.0.0. One solution is to call render inside your controller, passing the path of the view, as usual.

# app/controllers/confirmations_controller.rb
class ConfirmationsController < Devise::ConfirmationsController
  # Remove the first skip_before_filter (:require_no_authentication) if you
  # don't want to enable logged users to access the confirmation page.
  skip_before_filter :require_no_authentication
  skip_before_filter :authenticate_user!

  # PUT /resource/confirmation
  def update
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        @confirmable.attempt_set_password(params[:user])
        if @confirmable.valid? and @confirmable.password_match?
          do_confirm
        else
          do_show
          @confirmable.errors.clear #so that we wont render :new
        end
      else
        @confirmable.errors.add(:email, :password_already_set)
      end
    end

    if !@confirmable.errors.empty?
      self.resource = @confirmable
      render 'devise/confirmations/new' #Change this if you don't have the views on default path
    end
  end

  # GET /resource/confirmation?confirmation_token=abcdef
  def show
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        do_show
      else
        do_confirm
      end
    end
    unless @confirmable.errors.empty?
      self.resource = @confirmable
      render 'devise/confirmations/new' #Change this if you don't have the views on default path 
    end
  end
  
  protected

  def with_unconfirmed_confirmable
    original_token = params[:confirmation_token]
    confirmation_token = Devise.token_generator.digest(User, :confirmation_token, original_token)
    @confirmable = User.find_or_initialize_with_error_by(:confirmation_token, confirmation_token)
    if !@confirmable.new_record?
      @confirmable.only_if_unconfirmed {yield}
    end
  end

  def do_show
    original_token = params[:confirmation_token]
    confirmation_token = Devise.token_generator.digest(User, :confirmation_token, original_token)
    @confirmable = User.find_or_initialize_with_error_by(:confirmation_token, confirmation_token)
    @requires_password = true
    self.resource = @confirmable
    render 'devise/confirmations/show' #Change this if you don't have the views on default path
  end

  def do_confirm
    @confirmable.confirm
    set_flash_message :notice, :confirmed
    sign_in_and_redirect(resource_name, @confirmable)
  end
end

2) Add these methods to your user model:

  # new function to set the password without knowing the current 
  # password used in our confirmation controller. 
  def attempt_set_password(params)
    p = {}
    p[:password] = params[:password]
    p[:password_confirmation] = params[:password_confirmation]
    update_attributes(p)
  end

  # new function to return whether a password has been set
  def has_no_password?
    self.encrypted_password.blank?
  end

  # Devise::Models:unless_confirmed` method doesn't exist in Devise 2.0.0 anymore. 
  # Instead you should use `pending_any_confirmation`.  
  def only_if_unconfirmed
    pending_any_confirmation {yield}
  end

3) List the database fields explicitly for your migrations

Since Devise 2.0 helper methods for your migrations are no longer included. Instead, it explicitly lists the database fields.

class AddUserConfirmable < ActiveRecord::Migration
  def self.up
    add_column :users, :confirmation_token, :string
    add_column :users, :confirmed_at, :datetime
    add_column :users, :confirmation_sent_at, :datetime
    # add_column :users, :unconfirmed_email, :string # Only if using reconfirmable
    add_index :users, :confirmation_token, :unique => true

    User.update_all({:confirmed_at => DateTime.now, :confirmation_sent_at => DateTime.now})
  end

  def self.down
    remove_column :users, [:confirmed_at, :confirmation_token, :confirmation_sent_at]
  end
end

4) Update config/initializers/devise.rb

If not using reconfirmable, update the configuration in config/initializers/devise.rb

config.reconfirmable = false

Please note: you will also need to add a custom confirmations view and to override User#password_required? if you are using the Validatable strategy. See the examples for older versions below.

For Rails 3/4 & Devise 2.x-3.0, Devise 3.5.2+

You'll also need to copy the route changes and add the template as specified for For Rails 3 & Devise 1.2x, as well as any other parts of necessary code. If you are using the Validatable strategy you should also override User#password_required? as specified in For Rails 3 & Devise 1.2x.

1) Change render_with_scope method to render on ConfirmationsController

Devise::Controllers::ScopedViews::render_with_scope was removed in version 2.0.0. One solution is to call render inside your controller, passing the path of the view, as usual.

# app/controllers/confirmations_controller.rb
class ConfirmationsController < Devise::ConfirmationsController
  # Remove the first skip_before_filter (:require_no_authentication) if you
  # don't want to enable logged users to access the confirmation page.
  # If you are using rails 5.1+ use: skip_before_action
  skip_before_filter :require_no_authentication
  skip_before_filter :authenticate_user!

  # PUT /resource/confirmation
  def update
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        @confirmable.attempt_set_password(params[:user])
        if @confirmable.valid? and @confirmable.password_match?
          do_confirm
        else
          do_show
          @confirmable.errors.clear #so that we wont render :new
        end
      else
        @confirmable.errors.add(:email, :password_already_set)
      end
    end

    if !@confirmable.errors.empty?
      self.resource = @confirmable
      render 'devise/confirmations/new' #Change this if you don't have the views on default path
    end
  end

  # GET /resource/confirmation?confirmation_token=abcdef
  def show
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        do_show
      else
        do_confirm
      end
    end
    unless @confirmable.errors.empty?
      self.resource = @confirmable
      render 'devise/confirmations/new' #Change this if you don't have the views on default path 
    end
  end
  
  protected

  def with_unconfirmed_confirmable
    @confirmable = User.find_or_initialize_with_error_by(:confirmation_token, params[:confirmation_token])
    if !@confirmable.new_record?
      @confirmable.only_if_unconfirmed {yield}
    end
  end

  def do_show
    @confirmation_token = params[:confirmation_token]
    @requires_password = true
    self.resource = @confirmable
    render 'devise/confirmations/show' #Change this if you don't have the views on default path
  end

  def do_confirm
    @confirmable.confirm
    set_flash_message :notice, :confirmed
    sign_in_and_redirect(resource_name, @confirmable)
  end
end

2) Add these methods to your user model:

  def password_match?
     self.errors[:password] << I18n.t('errors.messages.blank') if password.blank?
     self.errors[:password_confirmation] << I18n.t('errors.messages.blank') if password_confirmation.blank?
     self.errors[:password_confirmation] << I18n.translate("errors.messages.confirmation", attribute: "password") if password != password_confirmation
     password == password_confirmation && !password.blank?
  end

  # new function to set the password without knowing the current 
  # password used in our confirmation controller. 
  def attempt_set_password(params)
    p = {}
    p[:password] = params[:password]
    p[:password_confirmation] = params[:password_confirmation]
    update_attributes(p)
  end

  # new function to return whether a password has been set
  def has_no_password?
    self.encrypted_password.blank?
  end

  # Devise::Models:unless_confirmed` method doesn't exist in Devise 2.0.0 anymore. 
  # Instead you should use `pending_any_confirmation`.  
  def only_if_unconfirmed
    pending_any_confirmation {yield}
  end

3) List the database fields explicitly for your migrations

Since Devise 2.0 helper methods for your migrations are no longer included. Instead, it explicitly lists the database fields.

class AddUserConfirmable < ActiveRecord::Migration
  def self.up
    add_column :users, :confirmation_token, :string
    add_column :users, :confirmed_at, :datetime
    add_column :users, :confirmation_sent_at, :datetime
    # add_column :users, :unconfirmed_email, :string # Only if using reconfirmable
    add_index :users, :confirmation_token, :unique => true

    User.update_all({:confirmed_at => DateTime.now, :confirmation_sent_at => DateTime.now})
  end

  def self.down
    remove_column :users, [:confirmed_at, :confirmation_token, :confirmation_sent_at]
  end
end

4) Update config/initializers/devise.rb

If not using reconfirmable, update the configuration in config/initializers/devise.rb

config.reconfirmable = false

For Rails 3 & Devise 1.2x

Note: If you desire to test this override inheritance using RSpec:

describe ConfirmationsController do

  it "should be a child of Devise::ConfirmationsController" do
    controller.class.superclass.should eq Devise::ConfirmationsController
  end

end

1) Override ConfirmationsController

# app/controllers/confirmations_controller.rb
class ConfirmationsController < Devise::ConfirmationsController
  # Remove the first skip_before_filter (:require_no_authentication) if you
  # don't want to enable logged users to access the confirmation page.
  skip_before_filter :require_no_authentication
  skip_before_filter :authenticate_user!

  # PUT /resource/confirmation
  def update
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        @confirmable.attempt_set_password(params[:user])
        if @confirmable.valid?
          do_confirm
        else
          do_show
          @confirmable.errors.clear #so that we wont render :new
        end
      else
        self.class.add_error_on(self, :email, :password_already_set)
      end
    end

    if !@confirmable.errors.empty?
      render_with_scope :new
    end
  end

  # GET /resource/confirmation?confirmation_token=abcdef
  def show
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        do_show
      else
        do_confirm
      end
    end
    if !@confirmable.errors.empty?
      self.resource = @confirmable
      render_with_scope :new
    end
  end
  
  protected

  def with_unconfirmed_confirmable
    @confirmable = User.find_or_initialize_with_error_by(:confirmation_token, params[:confirmation_token])
    if !@confirmable.new_record?
      @confirmable.only_if_unconfirmed {yield}
    end
  end

  def do_show
    @confirmation_token = params[:confirmation_token]
    @requires_password = true
    self.resource = @confirmable
    render_with_scope :show
  end

  def do_confirm
    @confirmable.confirm!
    set_flash_message :notice, :confirmed
    sign_in_and_redirect(resource_name, @confirmable)
  end
end

2) Add a custom confirmations view

# app/views/confirmations/show.html.erb
<h2>Account Activation</h2>

<%= form_for resource, :as => resource_name, :url => update_user_confirmation_path, :html => {:method => 'put'}, :id => 'activation-form' do |f| %>
  <%= devise_error_messages! %>
  <fieldset>
    <legend>Account Activation<% if resource.try(:user_name) %> for <%= resource.user_name %><% end %></legend>

  <% if @requires_password %>
      <p><%= f.label :password,'Choose a Password:' %> <%= f.password_field :password %></p>
      <p><%= f.label :password_confirmation,'Password Confirmation:' %> <%= f.password_field :password_confirmation %></p>
  <% end %>
  <%= hidden_field_tag :confirmation_token,@confirmation_token %>
  <p><%= f.submit "Activate" %></p>
  </fieldset>
<% end %>

Rails 4 change put to patch

<%= form_for resource, :as => resource_name, :url => update_user_confirmation_path, :html => {:method => 'patch'}, :id => 'activation-form' do |f| %>

3) Update config/routes.rb

  as :user do
      match '/user/confirmation' => 'confirmations#update', :via => :put, :as => :update_user_confirmation
  end
  devise_for :users, :controllers => { :confirmations => "confirmations" }

Rails 4 change put to patch

  as :user do
      patch '/user/confirmation' => 'confirmations#update', :via => :patch, :as => :update_user_confirmation
  end
  devise_for :users, :controllers => { :confirmations => "confirmations" }

4) Add these methods to your user model:

  # new function to set the password without knowing the current password used in our confirmation controller. 
  def attempt_set_password(params)
    p = {}
    p[:password] = params[:password]
    p[:password_confirmation] = params[:password_confirmation]
    update_attributes(p)
  end
  # new function to return whether a password has been set
  def has_no_password?
    self.encrypted_password.blank?
  end
  
  # new function to provide access to protected method unless_confirmed
  def only_if_unconfirmed
    unless_confirmed {yield}
  end

5) If you are using the Validatable strategy

You will also need to add the following to your user model:

def password_required?
  # Password is required if it is being set, but not for new records
  if !persisted? 
    false
  else
    !password.nil? || !password_confirmation.nil?
  end
end

6) If you are adding confirmation to an existing dataset

You can grandfather older accounts (no confirmation required) with the following migration:

class AddUserConfirmable < ActiveRecord::Migration
  def self.up
    change_table :users do |u|
      u.confirmable
    end

    User.update_all({:confirmed_at => DateTime.now, :confirmation_token => "Grandfathered Account", :confirmation_sent_at => DateTime.now})
  end

  def self.down
    remove_column :users, [:confirmed_at, :confirmation_token, :confirmation_sent_at]
  end
end

For Rails 2.3 & Devise 1.0.9

Override ConfirmationsController

# app/controllers/confirmations_controller.rb
class ConfirmationsController < ApplicationController
  include Devise::Controllers::InternalHelpers

  # GET /resource/confirmation/new
  def new
    build_resource
    render_with_scope :new
  end

  # POST /resource/confirmation
  def create
    self.resource = resource_class.send_confirmation_instructions(params[resource_name])

    if resource.errors.empty?
      set_flash_message :notice, :send_instructions
      redirect_to new_session_path(resource_name)
    else
      render_with_scope :new
    end
  end

  # PUT /resource/confirmation
  def update
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        @confirmable.attempt_set_password(params[:user])
        if @confirmable.valid?
          do_confirm
        else
          do_show
          @confirmable.errors.clear #so that we wont render :new
        end
      else
        self.class.add_error_on(self, :email, :password_already_set)
      end
    end

    if !@confirmable.errors.empty?
      render_with_scope :new
    end
  end

  # GET /resource/confirmation?confirmation_token=abcdef
  def show
    with_unconfirmed_confirmable do
      if @confirmable.has_no_password?
        do_show
      else
        do_confirm
      end
    end
    if !@confirmable.errors.empty?
      render_with_scope :new
    end
  end
  
  protected

  def with_unconfirmed_confirmable
    @confirmable = User.find_or_initialize_with_error_by(:confirmation_token, params[:confirmation_token])
    if !@confirmable.new_record?
      @confirmable.only_if_unconfirmed {yield}
    end
  end

  def do_show
    @confirmation_token = params[:confirmation_token]
    @requires_password = true
    self.resource = @confirmable
    render_with_scope :show
  end

  def do_confirm
    @confirmable.confirm!
    set_flash_message :notice, :confirmed
    sign_in_and_redirect(resource_name, @confirmable)
  end
end

2/ Add a custom confirmations view

# app/views/confirmations/show.html.erb
<h2>Account Activation</h2>
<% form_for resource_name, resource, :url => update_user_confirmation_path, :html => {:method => 'put'}, :id => 'activation-form' do |f| %>
  <%= f.error_messages %>
  <fieldset>
    <legend>Account Activation<% if resource.user_name %> for <%= resource.user_name %><% end %></legend>

  <% if @requires_password %>
      <p><%= f.label :password,'Choose a Password:' %> <%= f.password_field :password %></p>
      <p><%= f.label :password_confirmation,'Password Confirmation:' %> <%= f.password_field :password_confirmation %></p>
  <% end %>
  <%= hidden_field_tag :confirmation_token,@confirmation_token %>
  <p><%= f.submit "Activate" %></p>
  </fieldset>
<% end %>

3) Update config/routes.rb

map.update_user_confirmation '/user/confirmation', :controller => 'confirmations', :action => 'update', :conditions => { :method => :put }

4) Add some methods to your User/scope/whatever model

  # new function to set the password without knowing the current password used in our confirmation controller. 
  def attempt_set_password(params)
    p = {}
    p[:password] = params[:password]
    p[:password_confirmation] = params[:password_confirmation]
    update_attributes(p)
  end

  # new function to return whether a password has been set
  def has_no_password?
    self.encrypted_password.blank?
  end
  
  # new function to provide access to protected method unless_confirmed
  def only_if_unconfirmed
    unless_confirmed {yield}
  end
Clone this wiki locally
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.