allow user to update password with Devise and cancancan

踩坑 Devise 和 cancancan。

场景

使用devise做用户管理,cancancan做权限管理,生成model user,user含有name,avatar等字段,现在需要让user可以修改自己的密码,姓名,头像等。即:

  • 用户可以不修改密码,只修改姓名或者头像
  • 用户修改密码时,需要填写原来的密码来进行验证,同时新密码需要输入两次以确认

也就是,当用户输入current_password, password, password_confirmation中的任意一个时,就进行相关的密码验证,如果三者均为空,则不更新密码部分,更新其他属性。

实现步骤

在修改代码前,需要知道这两点:

  • 使用devise作用户管理时,如果你的 app/models/user.rb中,devise添加了validatable:

    devise :xxxx, :..., :validatable
    

那么相当于给user添加了如下的validates:

validates_uniqueness_of :email, allow_blank: true, if: :email_changed?
validates_format_of     :email, with: email_regexp, allow_blank: true, if: :email_changed?
validates_presence_of     :password, if: :password_required?
validates_confirmation_of :password, if: :password_required?
validates_length_of       :password, within: password_length, allow_blank: true

这里注意到validates_presence_of :password, if: :password_required?,当password_required?为true时,password必填。password_required?这个method的源代码如下:

def password_required?
  !persisted? || !password.nil? || !password_confirmation.nil?
end

如果更新的时候,我们不更新密码部分,只更新姓名或者头像,就会报错,因为此时表单送出的password是“”,即空值,但不是nil,这样 !password.nil? 就会为true,导致password_required?为true,所以我们需要重写这个method,当password为空时,也可以更新用户信息。

app/models/user.rb中,添加如下代码:

def password_required?
  new_record? || password.present? || password_confirmation.present?
end

这样添加后,当你修改用户信息时,如果password那一栏没有填写,不会出现如下报错:

  • 修改密码时,因为需要输入当前的密码,也就是current_password这个字段,而原先user中是没有添加这个字段的,所以需要在app/models/user.rb中,添加如下代码,保证current_password可读写。

    attr_writer :current_password
    

好了,准备工作完成,算是解决了大半了,我们的重头戏来了。

新增一个controller account::user 来让让用户完成修改密码的操作:

rails g controller account::user

routes.rb中添加路径:【因为修改密码是用户单个个体的行为,所以我们使用resource】

namespace :account do
  resource :user
end

我们添加上view,让用户可以修改个人信息:

touch app/views/account/users/show.html.erb

app/views/account/users/show.html.erb 中添加如下内容:

<%= simple_form_for @user, url: { action: :update } do |f| %>
  <%= f.input :name %>
  <%= f.file_field :avatar %>

  <%= f.input :current_password %>
  <%= f.input :password %>
  <%= f.input :password_confirmation %>

  <%= f.submit "更新", class: "btn btn-success"%>    
<% end %>

同时需要在其他页面,比如navbar中提供入口,让用户可以进入该页面更新个人信息,这里略去。

app/controllers/account/users_controller.rb中,添加如下内容:

class Account::UsersController < ApplicationController
  authorize_resource :user, parent: false
  def show
    @user = User.find(current_user.id)
  end

  def update
    @user = User.find(current_user.id)
    if user_params.values_at(:current_password, :password, :password_confirmation).any?(&:present?)
      @user.update_with_password(user_params)
    else
      @user.update(user_params)
    end
    redirect_to profile_path
  end

  protected

  def user_params
    params.require(:user).permit(:name, :avatar, :current_password, :password, :password_confirmation)
  end    
end

其中,因为使用cancancan做权限管理,所以这里我们用authorize_resource :user, parent: false来给user授权authorize,让user可以有读写的权利, update action部分中,我们使用了devise的instance method update_with_password来修改密码,它会检查用户的:current_password, :password, :password_confirmation这三者的有效性。

上面的代码看着有些不够简洁,我们稍稍重构下:

把@user = User.find(current_user.id)拎出来,放在before action里面,把[:current_password, :password, :password_confirmation]这三个密码相关的变量用一个method包起来。

class Account::UsersController < ApplicationController
  before_action -> { @user = User.find(current_user.id) }
  authorize_resource :user, parent: false

  def show
  end

  def update
    if user_params.values_at(*password_param_lists).any?(&:present?)
      @user.update_with_password(user_params)
    else
      @user.update(user_params)
    end
    redirect_to profile_path
  end

protected

  def user_params
    params.require(:user).permit(:name, :avatar, *password_param_lists)
  end

  def password_param_lists
    [:current_password, :password, :password_confirmation]
  end
end

OK! 基本功能实现。

参考

devise

cancancan