给新建用户发送密码重置邮件

用devise做用户登录,遇到的一个问题,记录下场景和leader指导出的解决方法。

场景:

添加项目成员member时,需要管理员新增用户user。

  • 管理员在新增用户页面中输入邮箱;
  • 管理员提交表单,用户的初始密码随机生成,随后给新建的用户发送一份账户激活的邮件;
  • 新增用户登录注册的邮箱,点击邮件中的链接【看似激活,实则为重置密码的链接】;
  • 用户重置密码完成,自动登录。

这里只涉及devise如何发送重置密码的邮件。

准备工作:

三个model:

对应的表结构:

# == Schema Information
#
# Table name: members
#
#  id         :integer          not null, primary key
#  user_id    :integer
#  project_id :integer
#  created_at :datetime         not null
#  updated_at :datetime         not null

# Table name: projects
#
#  id         :integer          not null, primary key
#  title      :string
#  created_at :datetime         not null
#  updated_at :datetime         not null

# Table name: users
#
#  id                     :integer          not null, primary key
#  email                  :string           default(""), not null
#  encrypted_password     :string           default(""), not null
#  reset_password_token   :string
#  reset_password_sent_at :datetime
#  remember_created_at    :datetime
#  sign_in_count          :integer          default(0), not null
#  current_sign_in_at     :datetime
#  last_sign_in_at        :datetime
#  current_sign_in_ip     :string
#  last_sign_in_ip        :string
#  created_at             :datetime         not null
#  updated_at             :datetime         not null

解决:

使用devise自带的sessions。在routes.rb中做如下修改:

### config/routes.rb

devise_for :users, path: "", controllers: {
    sessions: "sessions"
  }

新建一个sessionscontroller:rails g controller sessions

继承Devise::SessionsController.

### app/controllers/sessions_controller.rb

class SessionsController < Devise::SessionsController
end

如果希望root页面就是显示注册的页面,可以在routes.rb中添加如下代码:

### config/routes.rb

 devise_scope :user do
    get "/", controller: :sessions, action: :new
  end

激活账户涉及confirmable模块,使用rails g devise user 创建user时,默认没有confirmable的,需要自己手动添加,请在创建user时,确保含有以下字段:

t.string   :confirmation_token
t.datetime :confirmed_at
t.datetime :confirmation_sent_at
t.string   :unconfirmed_email # Only if using reconfirmable

「如果已经创建,使用rail g migration,给user添加上这些字段,同时迁移数据,给现有用户的confirmed_at赋值,不然无法登录。」

修改user.rb, 添加上模块::confirmable

### app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :rememberable, :validatable, :recoverable, :confirmable
  ......
end

同时在新增成员的表单中,允许输入用户的邮箱,如果邮箱存在,则直接添加该用户到项目成员中,如果邮箱不存在,则给该邮箱发送确认的邮件。

修改member.rb, 添加:

### app/models/member.rb

arr_accessor :email

这样member的new.html.erb中就可以使用f.input :email,随后在controller的create action中,通过传过来的email来判断用户是否已经存在。修改create中原来的@member.save,改成@member.submit, 在member.rb中添加submit 函数:

### app/models/member.rb

  def submit(email)
    password = SecureRandom.hex(4)
    user = User.where(email: email).first_or_initialize(password: password, password_confirmation: password)
    user.save
    self.user = user
    self.save
    errors.empty?
  end

### 这里省略了errors的delegate,如果email是无效的,需要将user的error delegate给member。

在members_controller中,修改create action:

#### app/controllers/projects/members_controller.rb

 def create
    if @member.submit(member_params[:email])
      @user = @member.user
      @user.send_activation_instructions unless @user.confirmed?
    end
  end

而对于send_activation_instructions,发送确认邮件的method,可以参考send_devise_notification 在 devise 中的定义:

### app/models/authenticatable.rb

def send_devise_notification(notification, *args)
  message = devise_mailer.send(notification, self, *args)
  # Remove once we move to Rails 4.2+ only.
  if message.respond_to?(:deliver_now)
    message.deliver_now
  else
    message.deliver
  end
end

这里message = devise_mailer.send(notification, self, *args),devise_mailer使用send,调用参数notification传过来的方法名。

看Devise::Mailer,里面定义了5个methods, 用于发送不同类型的邮件需求:

#### app/mailers/devise/mailer.rb

## 以下是confirmation_instructions。

def confirmation_instructions(record, token, opts={})
  @token = token
  devise_mail(record, :confirmation_instructions, opts)
end

.......

参考这个,我们可以写出activation_instructions。

在 user.rb中,定义: send_activation_instructions。

### app/models/user.rb

def send_activation_instructions
  token = set_reset_password_token ## 生成 reset_password_token,赋给token变量
  send_devise_notification(:activation_instructions, token, {})
end

新增一个mailer: UserMailer,继承 devise_mailer, 定义 activation_instructions。

### app/mailers/user_mailer.rb

class UserMailer < Devise::Mailer

  def activation_instructions(record, token, opts = {})
    @token = token
    devise_mail(record, :activation_instructions, opts)
  end
end

修改user.rb,添加:

### app/models/user.rb

def devise_mailer
  UserMailer
end

同时,设置 reconfirmable 为false:

### app/models/user.rb
  devise :database_authenticatable, :rememberable, :trackable, :validatable, :recoverable, :confirmable, reconfirmable: false
  ......

这样在member调用submit时,user.save就不会抛出异常,要求confirm这个账户了。

在user_mailer的添加activation_instructions.html.erb 文件,文件内容可参考devise的confirmation_instructions.html.erb.

### app/views/user_mailer/activation_instructions.html.erb

<p><%= t('.greeting', recipient: @resource.name) %></p>

<p><%= t('.instruction') %></p>
<p><%= link_to t('.action'), edit_reset_password_url(reset_password_token: @token) %></p>

这里注意把confirmation_instructions.html.erb中,confirmation_url替换成 edit_reset_password_url,因为我们希望的是用户点击这个链接,跳转到密码修改的页面。

最后,把reset_password这个功能添加进来。

  • 修改routes.rb,跳过devise自带的confirmations和passwords,用reset_password来完成验证邮箱和密码重置的功能,添加:

    ### config/routes.rb
    
      devise_for :users, path: "", controllers: {
        sessions: "sessions"
      }, skip: [ :confirmations, :passwords ]
    
      devise_scope :user do
        resource :reset_password
      end
    
  • 新建controller,继承自devise的passwordsController:

    class ResetPasswordsController < Devise::PasswordsController
    
      def update
        super do |resource|
          resource.confirm if resource.errors.empty? && !resource.confirmed?
        end
      end
    end
    

    这里重新定义了update, 调用super时,传递了一个block,如果user 没有confirmed过,同时更新密码成功,就调用confirm。在修改密码的同时,完成了用户的邮箱验证。

  • 在app/views/reset_passwords下,添加edit.html.erb,用户收到邮件时,点击“确认我的账户”时,会跳转到该页面,在该页面进行密码重置。具体页面内容,此处略。

BTW,有关邮箱的配置,可以参考rails的官方文档。在开发环境下,推荐安装 letter_opener这个gem, 就可以很轻松地查看是否正确发送了邮件了。

参考

devise

Action Mailer Basics