更多内容请访问 rubyonrails.org:

1. 简介

添加到任何应用程序最常见的功能之一是新用户注册的注册流程。我们目前构建的电子商务应用程序只有身份验证,用户必须在 Rails 控制台或脚本中创建。

此功能是添加其他功能之前的必要条件。例如,要让用户创建愿望清单,他们需要先注册才能创建与其账户关联的愿望清单。

让我们开始吧!

2. 添加注册

我们已经在《入门指南》中使用了 Rails 认证生成器,允许用户登录其账户。该生成器创建了一个 User 模型,其中包含数据库中的 email_address:stringpassword_digest:string 列。它还在 User 模型中添加了 has_secure_password,用于处理密码和确认。这解决了我们在应用程序中添加注册所需的大部分工作。

2.1. 为用户添加姓名

在注册时收集用户的姓名也是个好主意。这使我们能够个性化他们的体验,并在应用程序中直接称呼他们。让我们首先向数据库添加 first_namelast_name 列。

在终端中,使用这些列创建一个迁移

$ bin/rails g migration AddNamesToUsers first_name:string last_name:string

然后迁移数据库

$ bin/rails db:migrate

我们还添加一个方法来组合 first_namelast_name,以便我们可以显示用户的全名。

打开 app/models/user.rb 并添加以下内容

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  validates :first_name, :last_name, presence: true

  def full_name
    "#{first_name} #{last_name}"
  end
end

has_secure_password 只验证密码的存在。考虑为密码添加更多验证,例如最小长度或复杂性,以提高安全性。

接下来,让我们添加注册功能,以便我们可以注册新用户。

2.2. 注册路由与控制器

既然我们的数据库已经拥有注册新用户所需的所有列,下一步是为注册创建路由及其匹配的控制器。

config/routes.rb 中,让我们为注册添加一个资源

resource :session
resources :passwords, param: :token
resource :sign_up

我们在这里使用单数资源,因为我们希望 /sign_up 有一个单数路由。

此路由将请求定向到 app/controllers/sign_ups_controller.rb,所以我们现在就创建该控制器文件。

class SignUpsController < ApplicationController
  def show
    @user = User.new
  end
end

我们使用 show 动作创建一个新的 User 实例,该实例将用于显示注册表单。

接下来创建表单。创建 app/views/sign_ups/show.html.erb 并包含以下代码

<h1>Sign Up</h1>

<%= form_with model: @user, url: sign_up_path do |form| %>
  <% if form.object.errors.any? %>
    <div>Error: <%= form.object.errors.full_messages.first %></div>
  <% end %>

  <div>
    <%= form.label :first_name %>
    <%= form.text_field :first_name, required: true, autofocus: true, autocomplete: "given-name" %>
  </div>

  <div>
    <%= form.label :last_name %>
    <%= form.text_field :last_name, required: true, autocomplete: "family-name" %>
  </div>

  <div>
    <%= form.label :email_address %>
    <%= form.email_field :email_address, required: true, autocomplete: "email" %>
  </div>

  <div>
    <%= form.label :password %>
    <%= form.password_field :password, required: true, autocomplete: "new-password" %>
  </div>

  <div>
    <%= form.label :password_confirmation %>
    <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %>
  </div>

  <div>
    <%= form.submit "Sign up" %>
  </div>
<% end %>

此表单收集用户的姓名、电子邮件和密码。我们使用 autocomplete 属性帮助浏览器根据用户保存的信息建议这些字段的值。

您还会注意到我们在表单中设置了 url: sign_up_path 以及 model: @user。如果没有这个 url: 参数,form_with 将会认为我们有一个 User 并默认将表单发送到 /users。由于我们希望表单提交到 /sign_up,我们设置 url: 以覆盖默认路由。

回到 app/controllers/sign_ups_controller.rb,我们可以通过添加 create 动作来处理表单提交。

class SignUpsController < ApplicationController
  def show
    @user = User.new
  end

  def create
    @user = User.new(sign_up_params)
    if @user.save
      start_new_session_for(@user)
      redirect_to root_path
    else
      render :show, status: :unprocessable_entity
    end
  end

  private
    def sign_up_params
      params.expect(user: [ :first_name, :last_name, :email_address, :password, :password_confirmation ])
    end
end

create 动作分配参数并尝试将用户保存到数据库。如果成功,它会记录用户登录并重定向到 root_path,否则它会重新渲染带有错误的表单。

访问 https://:3000/sign_up 尝试一下。

2.3. 要求未认证访问

已认证用户仍然可以访问 SignUpsController 并在登录状态下创建另一个账户,这会让人感到困惑。

让我们通过在 app/controllers/concerns/authentication.rb 中的 Authentication 模块中添加一个辅助方法来解决这个问题。

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?
  end

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :require_authentication, **options
    end

    def unauthenticated_access_only(**options)
      allow_unauthenticated_access **options
      before_action -> { redirect_to root_path if authenticated? }, **options
    end

    # ...

unauthenticated_access_only 类方法可用于任何我们希望将操作限制为仅限未认证用户的控制器。

然后我们可以在 SignUpsController 的顶部使用这个方法。

class SignUpsController < ApplicationController
  unauthenticated_access_only

  # ...
end

2.4. 限制注册速率

我们的应用程序将在互联网上可访问,因此肯定会有恶意机器人和用户试图滥用我们的应用程序。我们可以为注册添加速率限制,以减慢任何提交过多请求的用户。

Rails 通过控制器中的 rate_limit 方法轻松实现这一点。

class SignUpsController < ApplicationController
  unauthenticated_access_only
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to sign_up_path, alert: "Try again later." }

  # ...
end

这将阻止在 3 分钟内发生超过 10 次的任何表单提交。

3. 编辑密码

现在用户可以登录了,让我们创建用户期望更新其个人资料、密码、电子邮件地址和其他设置的所有常见位置。

3.1. 使用命名空间

Rails 身份验证生成器已经在 app/controllers/passwords_controller.rb 创建了一个用于密码重置的控制器。这意味着我们需要使用不同的控制器来编辑已认证用户的密码。

为了防止冲突,我们可以使用一个称为命名空间的功能。命名空间将路由、控制器和视图组织到文件夹中,有助于防止像我们的两个密码控制器这样的冲突。

我们将创建一个名为“Settings”的命名空间,将用户和商店设置与应用程序的其余部分分开。

config/routes.rb 中,我们可以添加 Settings 命名空间以及一个用于编辑密码的资源

namespace :settings do
  resource :password, only: [ :show, :update ]
end

这将为编辑当前用户密码生成一个 /settings/password 路由,该路由与 /password 的密码重置路由是分开的。

3.2. 添加带命名空间的密码控制器与视图

命名空间还将控制器移动到 Ruby 中匹配的模块。此控制器将位于 settings 文件夹中以匹配命名空间。

让我们在 app/controllers/settings/passwords_controller.rb 创建文件夹和控制器,并从 show 动作开始。

class Settings::PasswordsController < ApplicationController
  def show
  end
end

视图也移动到 settings 文件夹中,所以让我们为这个动作创建文件夹和视图 app/views/settings/passwords/show.html.erb

<h1>Password</h1>

<%= form_with model: Current.user, url: settings_password_path do |form| %>
  <% if form.object.errors.any? %>
    <div><%= form.object.errors.full_messages.first %></div>
  <% end %>

  <div>
    <%= form.label :password_challenge %>
    <%= form.password_field :password_challenge, required: true, autocomplete: "current-password" %>
  </div>

  <div>
    <%= form.label :password %>
    <%= form.password_field :password, required: true, autocomplete: "new-password" %>
  </div>

  <div>
    <%= form.label :password_confirmation %>
    <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %>
  </div>

  <div>
    <%= form.submit "Update password" %>
  </div>
<% end %>

我们已设置 url: 参数,以确保表单提交到我们的命名空间路由并由 Settings::PasswordsController 处理。

传递 model: Current.user 还会告诉 form_with 提交一个 PATCH 请求,以使用 update 动作处理表单。

Current.user 来自 CurrentAttributes,它是一个按请求属性,在每个请求之前和之后自动重置。Rails 身份验证生成器使用它来跟踪已登录用户。

3.3. 安全更新密码

现在将 update 动作添加到控制器中。

class Settings::PasswordsController < ApplicationController
  def show
  end

  def update
    if Current.user.update(password_params)
      redirect_to settings_profile_path, status: :see_other, notice: "Your password has been updated."
    else
      render :show, status: :unprocessable_entity
    end
  end

  private
    def password_params
      params.expect(user: [ :password, :password_confirmation, :password_challenge ]).with_defaults(password_challenge: "")
    end
end

出于安全考虑,我们需要确保只有用户自己才能更新其密码。我们 User 模型中的 has_secure_password 方法提供了这个属性。如果 password_challenge 存在,它将根据数据库中用户的当前密码验证密码挑战,以确认其匹配。

恶意用户可能会尝试删除浏览器中的 password_challenge 字段以绕过此验证。为了防止这种情况并确保验证始终运行,我们使用 .with_defaults(password_challenge: "") 来设置默认值,即使 password_challenge 参数缺失。

您现在可以访问 https://:3000/settings/password 来更新您的密码。

3.4. 重命名密码挑战属性

虽然 password_challenge 对我们的代码来说是一个好名字,但用户习惯于将此表单字段视为“当前密码”。我们可以在 Rails 中使用区域设置来重命名它,以改变此属性在前端的显示方式。

将以下内容添加到 config/locales/en.yml

en:
  hello: "Hello world"
  products:
    index:
      title: "Products"

  activerecord:
    attributes:
      user:
        password_challenge: "Current password"

要了解更多信息,请查阅 I18n 指南

4. 编辑用户资料

接下来,让我们添加一个页面,以便用户可以编辑其资料,例如更新其名字和姓氏。

4.1. 资料路由与控制器

config/routes.rb 中,在 settings 命名空间下添加一个 profile 资源。我们还可以为该命名空间添加一个根路由,以处理对 /settings 的任何访问,并将其重定向到个人资料设置。

namespace :settings do
  resource :password, only: [ :show, :update ]
  resource :profile, only: [ :show, :update ]

  root to: redirect("/settings/profile")
end

让我们在 app/controllers/settings/profiles_controller.rb 创建用于编辑个人资料的控制器。

class Settings::ProfilesController < ApplicationController
  def show
  end

  def update
    if Current.user.update(profile_params)
      redirect_to settings_profile_path, status: :see_other, notice: "Your profile was updated successfully."
    else
      render :show, status: :unprocessable_entity
    end
  end

  private
    def profile_params
      params.expect(user: [ :first_name, :last_name ])
    end
end

这与密码控制器非常相似,但只允许更新用户的个人资料详细信息,如名字和姓氏。

然后创建 app/views/settings/profiles/show.html.erb 以显示编辑个人资料表单。

<h1>Profile</h1>

<%= form_with model: Current.user, url: settings_profile_path do |form| %>
  <% if form.object.errors.any? %>
    <div>Error: <%= form.object.errors.full_messages.first %></div>
  <% end %>

  <div>
    <%= form.label :first_name %>
    <%= form.text_field :first_name, required: true, autocomplete: "given-name" %>
  </div>

  <div>
    <%= form.label :last_name %>
    <%= form.text_field :last_name, required: true, autocomplete: "family-name" %>
  </div>

  <div>
    <%= form.submit "Update profile" %>
  </div>
<% end %>

您现在可以访问 https://:3000/settings/profile 来更新您的姓名。

4.2. 更新导航

让我们更新导航,在“退出”按钮旁边添加一个“设置”链接。

打开 app/views/layouts/application.html.erb 并更新导航栏。

<!DOCTYPE html>
<html>
  <head>
    <%# ... %>
  </head>

  <body>
    <div class="notice"><%= flash[:notice] %></div>
    <div class="alert"><%= flash[:alert] %></div>

    <nav class="navbar">
      <%= link_to "Home", root_path %>
      <% if authenticated? %>
        <%= link_to "Settings", settings_root_path %>
        <%= button_to "Log out", session_path, method: :delete %>
      <% else %>
        <%= link_to "Sign Up", sign_up_path %>
        <%= link_to "Login", new_session_path %>
      <% end %>
    </nav>

现在,当您进行身份验证后,导航栏中将显示一个“设置”链接。

4.3. 设置布局

既然我们在这里,让我们为“设置”添加一个新布局,以便我们可以在侧边栏中组织它们。为此,我们将使用嵌套布局

嵌套布局允许您添加 HTML(例如侧边栏),同时仍渲染应用程序布局。这意味着我们不必在“设置”布局中重复我们的头部标签或导航。

让我们创建 app/views/layouts/settings.html.erb 并添加以下内容

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Password", settings_password_path %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

在设置布局中,我们为侧边栏提供 HTML 并告诉 Rails 将应用程序布局渲染为父级。

我们需要修改应用程序布局以使用 yield(:content) 渲染嵌套布局中的内容。

<!DOCTYPE html>
<html>
  <head>
    <%# ... %>
  </head>

  <body>
    <div class="notice"><%= flash[:notice] %></div>
    <div class="alert"><%= flash[:alert] %></div>

    <nav class="navbar">
      <%= link_to "Home", root_path %>
      <% if authenticated? %>
        <%= link_to "Settings", settings_root_path %>
        <%= button_to "Log out", session_path, method: :delete %>
      <% else %>
        <%= link_to "Sign Up", sign_up_path %>
        <%= link_to "Login", new_session_path %>
      <% end %>
    </nav>

    <main>
      <%= content_for?(:content) ? yield(:content) : yield %>
    </main>
  </body>
</html

这允许应用程序控制器正常使用 yield,或者如果嵌套布局中使用了 content_for(:content),它也可以作为父布局。

我们现在有两个独立的 <nav> 标签,所以我们需要更新现有的 CSS 选择器以避免冲突。

为此,请将 .navbar 类添加到 app/assets/stylesheets/application.css 中的这些选择器。

nav.navbar {
  justify-content: flex-end;
  display: flex;
  font-size: 0.875em;
  gap: 0.5rem;
  max-width: 1024px;
  margin: 0 auto;
  padding: 1rem;
}

nav.navbar a {
  display: inline-block;
}

然后添加一些 CSS 以将“设置”导航显示为侧边栏。

section.settings {
  display: flex;
  gap: 1rem;
}

section.settings nav {
  width: 200px;
}

section.settings nav a {
  display: block;
}

要使用这个新布局,我们可以告诉控制器我们想要使用特定的布局。我们可以将 layout "settings" 添加到任何控制器来更改渲染的布局。

由于我们会有许多控制器使用此布局,我们可以创建一个基类来定义共享配置并使用继承来使用它们。

添加 app/controllers/settings/base_controller.rb 并添加以下内容

class Settings::BaseController < ApplicationController
  layout "settings"
end

然后更新 app/controllers/settings/passwords_controller.rb 以继承自此控制器。

class Settings::PasswordsController < Settings::BaseController

并更新 app/controllers/settings/profiles_controller.rb 也继承自它。

class Settings::ProfilesController < Settings::BaseController

5. 删除账户

接下来,让我们添加删除账户的功能。我们将从向 config/routes.rb 添加另一个带命名空间的账户路由开始。

namespace :settings do
  resource :password, only: [ :show, :update ]
  resource :profile, only: [ :show, :update ]
  resource :user, only: [ :show, :destroy ]

  root to: redirect("/settings/profile")
end

为了处理这些新路由,创建 app/controllers/settings/users_controller.rb 并添加以下内容

class Settings::UsersController < Settings::BaseController
  def show
  end

  def destroy
    terminate_session
    Current.user.destroy
    redirect_to root_path, notice: "Your account has been deleted."
  end
end

删除账户的控制器非常简单。我们有一个 show 动作来显示页面,一个 destroy 动作来注销并删除用户。它还继承自 Settings::BaseController,因此它将像其他控制器一样使用设置布局。

现在让我们在 app/views/settings/users/show.html.erb 添加视图,内容如下

<h1>Account</h1>

<%= button_to "Delete my account", settings_user_path, method: :delete, data: { turbo_confirm: "Are you sure? This cannot be undone." } %>

最后,我们将在设置布局的侧边栏中添加一个“账户”链接。

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Password", settings_password_path %>
      <%= link_to "Account", settings_user_path %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

就这样!您现在可以删除您的账户了。

6. 更新电子邮件地址

偶尔,用户需要更改其账户上的电子邮件地址。为了安全地执行此操作,我们需要存储新的电子邮件地址并发送电子邮件以确认更改。

6.1. 为用户添加未确认电子邮件

我们将首先在数据库的用户表中添加一个新字段。这将存储新的电子邮件地址,同时我们等待确认。

$ bin/rails g migration AddUnconfirmedEmailToUsers unconfirmed_email:string

然后迁移数据库。

$ bin/rails db:migrate

6.2. 电子邮件路由与控制器

接下来,我们可以在 config/routes.rb 中的 :settings 命名空间下添加一个电子邮件路由。

namespace :settings do
  resource :email, only: [ :show, :update ]
  resource :password, only: [ :show, :update ]
  resource :profile, only: [ :show, :update ]
  resource :user, only: [ :show, :destroy ]

  root to: redirect("/settings/profile")
end

然后我们将创建 app/controllers/settings/emails_controller.rb 来显示它。

class Settings::EmailsController < Settings::BaseController
  def show
  end
end

最后,我们将在 app/views/settings/emails/show.html.erb 创建视图

<h1>Change Email</h1>

<%= form_with model: Current.user, url: settings_email_path do |form| %>
  <% if form.object.errors.any? %>
    <div>Error: <%= form.object.errors.full_messages.first %></div>
  <% end %>

  <div>
    <%= form.label :unconfirmed_email, "New email address" %>
    <%= form.email_field :unconfirmed_email, required: true %>
  </div>

  <div>
    <%= form.label :password_challenge %>
    <%= form.password_field :password_challenge, required: true, autocomplete: "current-password" %>
  </div>

  <div>
    <%= form.submit "Update email address" %>
  </div>
<% end %>

为了保持安全,我们需要请求新的电子邮件地址并验证用户的当前密码,以确保只有账户所有者才能更改电子邮件。

在我们的控制器中,我们将验证当前密码并保存新的电子邮件地址,然后发送一封电子邮件以确认新的电子邮件地址。

class Settings::EmailsController < Settings::BaseController
  def show
  end

  def update
    if Current.user.update(email_params)
      UserMailer.with(user: Current.user).email_confirmation.deliver_later
      redirect_to settings_email_path, status: :see_other, notice: "We've sent a verification email to #{Current.user.unconfirmed_email}."
    else
      render :show, status: :unprocessable_entity
    end
  end

  private
    def email_params
      params.expect(user: [ :password_challenge, :unconfirmed_email ]).with_defaults(password_challenge: "")
    end
end

这使用了与 Settings::PasswordsController 相同的 with_defaults(password_challenge: "") 来触发密码挑战验证。

我们还没有创建 UserMailer,所以接下来我们来创建它。

6.3. 新电子邮件确认

让我们使用邮件生成器创建在 Settings::EmailsController 中引用的 UserMailer

$ bin/rails generate mailer User email_confirmation
      create  app/mailers/user_mailer.rb
      invoke  erb
      create    app/views/user_mailer
      create    app/views/user_mailer/email_confirmation.text.erb
      create    app/views/user_mailer/email_confirmation.html.erb
      invoke  test_unit
      create    test/mailers/user_mailer_test.rb
      create    test/mailers/previews/user_mailer_preview.rb

我们需要生成一个令牌以包含在电子邮件正文中。打开 app/models/user.rb 并添加以下内容

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  validates :first_name, :last_name, presence: true

  generates_token_for :email_confirmation, expires_in: 7.days do
    unconfirmed_email
  end

  def confirm_email
    update(email_address: unconfirmed_email, unconfirmed_email: nil)
  end

  def full_name
    "#{first_name} #{last_name}"
  end
end

这添加了一个我们可以用于电子邮件确认的令牌生成器。该令牌对未确认的电子邮件进行编码,因此如果电子邮件更改或令牌过期,它将变为无效。

让我们更新 app/mailers/user_mailer.rb 以生成电子邮件的新令牌

class UserMailer < ApplicationMailer
  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.user_mailer.email_confirmation.subject
  def email_confirmation
    @token = params[:user].generate_token_for(:email_confirmation)
    mail to: params[:user].unconfirmed_email
  end
end

我们将把令牌包含在 app/views/user_mailer/email_confirmation.html.erb 的 HTML 视图中

<h1>Verify your email address</h1>

<p><%= link_to "Confirm your email", email_confirmation_url(token: @token) %></p>

以及 app/views/user_mailer/email_confirmation.text.erb

Confirm your email: <%= email_confirmation_url(token: @token) %>

6.4. 电子邮件确认控制器

确认邮件包含一个指向我们的 Rails 应用程序的链接,用于验证电子邮件更改。

让我们在 config/routes.rb 中为此添加一个路由

namespace :email do
  resources :confirmations, param: :token, only: [ :show ]
end

当用户点击电子邮件中的链接时,它将打开浏览器并向应用程序发出 GET 请求。这意味着我们只需要此控制器的 show 动作。

接下来,将以下内容添加到 app/controllers/email/confirmations_controller.rb

class Email::ConfirmationsController < ApplicationController
  allow_unauthenticated_access

  def show
    user = User.find_by_token_for(:email_confirmation, params[:token])
    if user&.confirm_email
      flash[:notice] = "Your email has been confirmed."
    else
      flash[:alert] = "Invalid token."
    end
    redirect_to root_path
  end
end

无论用户是否经过身份验证,我们都希望确认电子邮件地址,因此此控制器允许未经身份验证的访问。我们使用 find_by_token_for 方法验证令牌并查找匹配的 User 记录。如果成功,我们调用 confirm_email 方法更新用户的电子邮件并将 unconfirmed_email 重置为 nil。如果令牌无效,user 变量将为 nil,我们将显示警报消息。

最后,让我们在设置布局侧边栏中添加一个“电子邮件”链接

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Email", settings_email_path %>
      <%= link_to "Password", settings_password_path %>
      <%= link_to "Account", settings_user_path %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

通过导航到 https://:3000/settings/email 并更新您的电子邮件地址来测试此过程。查看 Rails 服务器日志中的电子邮件内容,并在浏览器中打开确认链接以更新数据库中的电子邮件。

7. 分离管理员与用户

现在任何人都可以注册我们的商店账户,我们需要区分普通用户和管理员。

7.1. 添加管理员标志

我们将首先在用户表中添加一列。

$ bin/rails g migration AddAdminToUsers admin:boolean

然后迁移数据库。

$ bin/rails db:migrate

admin 设置为 trueUser 应该能够添加和删除产品并访问商店的其他管理区域。

7.2. 只读属性

我们需要非常小心,admin 不能被任何恶意用户编辑。通过将 :admin 属性从任何允许的参数列表中排除,这很容易实现。

可选地,我们可以将 admin 属性标记为只读以增加安全性。这将告诉 Rails 任何时候 admin 属性被更改时都抛出错误。它仍然可以在创建记录时设置,但为防止未经授权的更改提供了一层额外的安全性。如果您经常更改用户的 admin 标志,您可能希望跳过此操作,但在我们的电子商务商店中,它是一个有用的保护措施。

我们可以在模型中添加 attr_readonly 来保护属性不被更新。

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  attr_readonly :admin

  # ...

admin 是只读时,我们必须直接在数据库中更新它,而不是使用 Active Record。

Rails 有一个名为 dbconsole 的命令,它将打开一个数据库控制台,我们可以在其中使用 SQL 直接与数据库交互。

$ bin/rails dbconsole
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite>

在 SQLite 提示符中,我们可以使用 UPDATE 语句并使用 WHERE 过滤到单个用户 ID 来更新记录的 admin 列。

UPDATE users SET admin=true WHERE users.id=1;

要关闭 SQLite 提示符,请输入以下命令

.quit

8. 查看所有用户

作为商店管理员,我们希望查看和管理用户,以用于客户支持、营销和其他用例。

首先,我们需要在 config/routes.rb 中的新 store 命名空间中为用户添加一个路由。

# Admins Only
namespace :store do
  resources :users
end

8.1. 添加仅限管理员访问

用户控制器应仅供管理员访问。在我们创建该控制器之前,让我们创建一个 Authorization 模块,其中包含一个类方法,以将访问权限限制为仅管理员。

创建 app/controllers/concerns/authorization.rb 并包含以下代码

module Authorization
  extend ActiveSupport::Concern

  class_methods do
    def admin_access_only(**options)
      before_action -> { redirect_to root_path, alert: "You aren't allowed to do that." unless authenticated? && Current.user.admin? }, **options
    end
  end
end

要在我们的控制器中使用此模块,请将其包含在 app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Authentication
  include Authorization

  # ...

Authorization 模块功能可用于我们应用程序中的任何控制器。此模块为任何额外的辅助方法提供了一个家,以便将来管理管理员或其他类型角色的访问权限。

8.2. 用户控制器与视图

首先,在 app/controllers/store/base_controller.rb 创建 store 命名空间的基类。

class Store::BaseController < ApplicationController
  admin_access_only
  layout "settings"
end

此控制器将使用我们刚刚创建的 admin_access_only 方法将访问权限限制为仅管理员。它还将使用相同的设置布局来显示侧边栏。

接下来,创建 app/controllers/store/users_controller.rb 并添加以下内容

class Store::UsersController < Store::BaseController
  before_action :set_user, only: %i[ show edit update destroy ]

  def index
    @users = User.all
  end

  def show
  end

  def edit
  end

  def update
    if @user.update(user_params)
      redirect_to store_user_path(@user), status: :see_other, notice: "User has been updated"
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
  end

  private
    def set_user
      @user = User.find(params[:id])
    end

    def user_params
      params.expect(user: [ :first_name, :last_name, :email_address ])
    end
end

这使得管理员能够读取、更新和删除数据库中的用户。

接下来,让我们创建索引视图 app/views/store/users/index.html.erb

<h1><%= pluralize @users.count, "user" %></h1>

<% @users.each do |user| %>
  <div>
    <%= link_to user.full_name, store_user_path(user) %>
  </div>
<% end %>

然后,编辑用户视图 app/views/store/users/edit.html.erb

<h1>Edit User</h1>
<%= render "form", user: @user %>

以及表单局部视图 app/views/store/users/_form.html.erb

<%= form_with model: [ :store, user ] do |form| %>
  <div>
    <%= form.label :first_name %>
    <%= form.text_field :first_name, required: true, autofocus: true, autocomplete: "given-name" %>
  </div>

  <div>
    <%= form.label :last_name %>
    <%= form.text_field :last_name, required: true, autocomplete: "family-name" %>
  </div>

  <div>
    <%= form.label :email_address %>
    <%= form.email_field :email_address, required: true, autocomplete: "email" %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

最后是用户详情视图 app/views/store/users/show.html.erb

<%= link_to "Back to all users", store_users_path %>

<h1><%= @user.full_name %></h1>
<p><%= @user.email_address %></p>

<div>
  <%= link_to "Edit user", edit_store_user_path(@user)  %>
  <%= button_to "Delete user", store_user_path(@user), method: :delete, data: { turbo_confirm: "Are you sure?" } %>
</div>

8.3. 设置导航

接下来,我们希望将其添加到“设置”侧边栏导航中。由于这应该只对管理员可见,我们需要将其封装在一个条件中,以确保当前用户是管理员。

将以下内容添加到 app/views/layouts/settings.html.erb 中的设置布局中

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Email", settings_email_path %>
      <%= link_to "Password", settings_password_path %>
      <%= link_to "Account", settings_user_path %>

      <% if Current.user.admin? %>
        <h4>Store Settings</h4>
        <%= link_to "Users", store_users_path %>
      <% end %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

9. 分离产品控制器

既然我们已经将普通用户和管理员分开,我们就可以重新组织我们的产品控制器以利用这一变化。我们可以将产品控制器分为两个:一个面向公众,一个面向管理员。

面向公众的控制器将处理店面视图,而管理员控制器将处理产品管理。

9.1. 公共产品控制器

对于公共店面,我们只需要让用户查看产品。这意味着 app/controllers/products_controller.rb 可以简化为以下内容。

class ProductsController < ApplicationController
  allow_unauthenticated_access

  def index
    @products = Product.all
  end

  def show
    @product = Product.find(params[:id])
  end
end

然后我们可以调整产品控制器的视图。

首先,让我们将这些视图复制到 store 命名空间,因为这是我们希望管理商店产品的地方。

$ cp -R app/views/products app/views/store

9.2. 清理公共产品视图

现在让我们从公共产品视图中删除所有创建、更新和删除功能。

app/views/products/index.html.erb 中,让我们删除“新产品”的链接。我们将改用“设置”区域来创建新产品。

-<%= link_to "New product", new_product_path if authenticated? %>

删除 app/views/products/show.html.erb 中的“编辑”和“删除”链接

-    <% if authenticated? %>
-      <%= link_to "Edit", edit_product_path(@product) %>
-      <%= button_to "Delete", @product, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
-    <% end %>

然后删除

  • app/views/products/new.html.erb
  • app/views/products/edit.html.erb
  • app/views/products/_form.html.erb

9.3. 管理员产品 CRUD

首先,让我们将产品命名空间路由添加到 config/routes.rb,并为这个命名空间设置一个根路由

  namespace :store do
    resources :products
    resources :users

    root to: redirect("/store/products")
  end

然后更新 app/views/layouts/settings.html.erb 中的设置布局导航

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Email", settings_email_path %>
      <%= link_to "Password", settings_password_path %>
      <%= link_to "Account", settings_user_path %>

      <% if Current.user.admin? %>
        <h4>Store Settings</h4>
        <%= link_to "Products", store_products_path %>
        <%= link_to "Users", store_users_path %>
      <% end %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

接下来,创建 app/controllers/store/products_controller.rb,内容如下

class Store::ProductsController < Store::BaseController
  before_action :set_product, only: %i[ show edit update destroy ]

  def index
    @products = Product.all
  end

  def show
  end

  def new
    @product = Product.new
  end

  def create
    @product = Product.new(product_params)
    if @product.save
      redirect_to store_product_path(@product)
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
  end

  def update
    if @product.update(product_params)
      redirect_to store_product_path(@product)
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @product.destroy
    redirect_to store_products_path
  end

  private
    def set_product
      @product = Product.find(params[:id])
    end

    def product_params
      params.expect(product: [ :name, :description, :featured_image, :inventory_count ])
    end
end

此控制器与之前的 ProductsController 几乎相同,但有两个重要更改

  1. 我们有 admin_access_only 来限制访问权限,仅限于管理员用户。
  2. 重定向使用 store 命名空间,以将用户保留在商店设置区域。

9.4. 更新管理员产品视图

管理员视图需要进行一些调整才能在 store 命名空间内工作。

首先,让我们通过更新 model: 参数以使用 store 命名空间来修复 app/views/store/products/_form.html.erb 中的表单。

<%= form_with model: [ :store, product ] do |form| %>
  <%# ... %>

然后我们可以从 app/views/store/products/index.html.erb 中移除 authenticated? 检查,并为链接使用 store 命名空间

<h1><%= t ".title" %></h1>

<%= link_to "New product", new_store_product_path %>

<div id="products">
  <% @products.each do |product| %>
    <div>
      <%= link_to product.name, store_product_path(product) %>
    </div>
  <% end %>
</div>

由于此视图现在位于 store 命名空间中,因此找不到 h1 标签的相对翻译。我们可以向 config/locales/en.yml 添加另一个翻译来解决此问题

en:
  hello: "Hello world"
  products:
    index:
      title: "Products"

  store:
    products:
      index:
        title: "Products"

  activerecord:
    attributes:
      user:
        password_challenge: "Current password"

我们需要更新 app/views/store/products/new.html.erb 中的“取消”链接以使用 store 命名空间

<h1>New product</h1>

<%= render "form", product: @product %>
<%= link_to "Cancel", store_products_path %>

app/views/store/products/edit.html.erb 中也做同样的操作

<h1>Edit product</h1>

<%= render "form", product: @product %>
<%= link_to "Cancel", store_product_path(@product) %>

app/views/store/products/show.html.erb 更新为以下内容

<p><%= link_to "Back", store_products_path %></p>

<section class="product">
  <%= image_tag @product.featured_image if @product.featured_image.attached? %>

  <section class="product-info">
    <% cache @product do %>
      <h1><%= @product.name %></h1>
      <%= @product.description %>
    <% end %>

    <%= link_to "View in Storefront", @product %>
    <%= link_to "Edit", edit_store_product_path(@product) %>
    <%= button_to "Delete", [ :store, @product ], method: :delete, data: { turbo_confirm: "Are you sure?" } %>
  </section>
</section>

这更新了 show 动作,使得

  • 链接现在使用 store 命名空间。
  • 添加了一个“在店面查看”链接,以便管理员更容易查看产品对公众的展示效果。
  • 库存局部视图已移除,因为它仅在公共店面有用。

由于我们在管理区域不使用 _inventory.html.erb 局部视图,让我们将其删除

$ rm app/views/store/products/_inventory.html.erb

10. 添加测试

让我们添加一些测试来验证我们的功能是否正常工作。

10.1. 认证测试辅助方法

在我们的测试套件中,我们需要在测试中登录用户。Rails 认证生成器已更新以包含认证辅助方法,但您的应用程序可能是在此之前创建的,因此让我们在编写测试之前确保这些文件存在。

test/test_helpers/session_test_helper.rb 中,您应该会看到以下内容。如果您没有,请继续创建此文件。

module SessionTestHelper
  def sign_in_as(user)
    Current.session = user.sessions.create!

    ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
      cookie_jar.signed[:session_id] = Current.session.id
      cookies[:session_id] = cookie_jar[:session_id]
    end
  end

  def sign_out
    Current.session&.destroy!
    cookies.delete(:session_id)
  end
end

test/test_helper.rb 中,您应该会看到这些行。如果不是,请继续添加它们。

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
require_relative "test_helpers/session_test_helper"

module ActiveSupport
  class TestCase
    include SessionTestHelper

    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)

    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
    fixtures :all

    # Add more helper methods to be used by all tests here...
  end
end

10.2. 测试注册

我们有几件不同的事情要测试注册。让我们从一个简单的测试开始,查看页面。

test/controllers/sign_ups_controller_test.rb 中创建一个控制器测试,内容如下

require "test_helper"

class SignUpsControllerTest < ActionDispatch::IntegrationTest
  test "view sign up" do
    get sign_up_path
    assert_response :success
  end
end

此测试将访问 /sign_up 并确保它收到 200 OK 响应。

让我们运行测试,看看它是否通过

$ bin/rails test test/controllers/sign_ups_controller_test.rb:4
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 5967

# Running:

.

Finished in 0.559107s, 1.7886 runs/s, 1.7886 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

接下来,让我们登录一个用户,然后尝试访问注册页面。在这种情况下,用户应该被重定向,因为他们已经通过身份验证。

将以下测试添加到文件中。

test "view sign up when authenticated" do
  sign_in_as users(:one)
  get sign_up_path
  assert_redirected_to root_path
end

再次运行测试,您应该会看到这个测试也通过了。

接下来,让我们添加一个测试,以确保当用户填写表单时会创建一个新用户。

test "successful sign up" do
  assert_difference "User.count" do
    post sign_up_path, params: { user: { first_name: "Example", last_name: "User", email_address: "example@user.org", password: "password", password_confirmation: "password" } }
    assert_redirected_to root_path
  end
end

对于此测试,我们需要使用 POST 请求提交参数来测试 create 动作。

我们还要用无效数据进行测试,以确保控制器返回错误。

test "invalid sign up" do
  assert_no_difference "User.count" do
    post sign_up_path, params: { user: { email_address: "example@user.org", password: "password", password_confirmation: "password" } }
    assert_response :unprocessable_entity
  end
end

此测试应为无效,因为缺少用户名。由于此请求无效,我们需要断言响应为 422 Unprocessable Entity。我们还可以断言 User.count 没有差异,以确保没有创建用户。

另一个重要的测试是确保注册不接受 admin 属性。

test "sign up ignores admin attribute" do
  assert_difference "User.count" do
    post sign_up_path, params: { user: { first_name: "Example", last_name: "User", email_address: "example@user.org", password: "password", password_confirmation: "password", admin: true } }
    assert_redirected_to root_path
  end
  refute User.find_by(email_address: "example@user.org").admin?
end

此测试就像一次成功的注册,但它尝试将 admin: true。在断言用户已创建后,我们还需要断言该用户不是管理员。

10.3. 测试电子邮件更改

更改用户的电子邮件是一个多步骤的过程,同样重要。我们应该对其进行测试。

首先,让我们创建一个控制器测试,以确保电子邮件更新表单正确处理所有内容。

test/controllers/settings/emails_controller_test.rb 中添加以下内容

require "test_helper"

class Settings::EmailsControllerTest < ActionDispatch::IntegrationTest
  test "validates current password" do
    user = users(:one)
    sign_in_as user
    patch settings_email_path, params: { user: { password_challenge: "invalid", unconfirmed_email: "new@example.org" } }
    assert_response :unprocessable_entity
    assert_nil user.reload.unconfirmed_email
    assert_no_emails
  end
end

我们的第一个测试将是提交一个无效的密码挑战。为此,我们希望确保响应是错误,并且未确认的电子邮件未更改。我们还可以确保在这种情况下也没有发送电子邮件。

然后我们可以为成功案例编写测试

test "sends email confirmation on successful update" do
  user = users(:one)
  sign_in_as user
  patch settings_email_path, params: { user: { password_challenge: "password", unconfirmed_email: "new@example.org" } }
  assert_response :redirect
  assert_equal "new@example.org", user.reload.unconfirmed_email
  assert_enqueued_email_with UserMailer, :email_confirmation, params: { user: user }
end

此测试提交成功参数,确认电子邮件已保存到数据库,用户已重定向,并且确认电子邮件已排队等待发送。

让我们运行这些测试并确保它们通过

$ bin/rails test test/controllers/settings/emails_controller_test.rb
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 31545

# Running:

..

Finished in 0.954590s, 2.0951 runs/s, 6.2854 assertions/s.
2 runs, 6 assertions, 0 failures, 0 errors, 0 skips

我们还需要测试 Email::ConfirmationsController,以确保确认令牌已验证,并且电子邮件更新过程成功完成。

让我们在 test/controllers/email/confirmations_controller_test.rb 添加另一个控制器测试,内容如下

require "test_helper"

class Email::ConfirmationsControllerTest < ActionDispatch::IntegrationTest
  test "invalid tokens are ignored" do
    user = users(:one)
    previous_email = user.email_address
    user.update(unconfirmed_email: "new@example.org")
    get email_confirmation_path(token: "invalid")
    assert_equal "Invalid token.", flash[:alert]
    user.reload
    assert_equal previous_email, user.email_address
  end

  test "email is updated with a valid token" do
    user = users(:one)
    user.update(unconfirmed_email: "new@example.org")
    get email_confirmation_path(token: user.generate_token_for(:email_confirmation))
    assert_equal "Your email has been confirmed.", flash[:notice]
    user.reload
    assert_equal "new@example.org", user.email_address
    assert_nil user.unconfirmed_email
  end
end

第一个测试模拟用户使用无效令牌确认其电子邮件更改。我们断言已设置错误消息,并且电子邮件地址未更改。

第二个测试使用有效令牌,并断言已设置成功通知,并且数据库中的电子邮件地址已更新。

我们需要修复另一个与电子邮件确认相关的测试,那就是为 UserMailer 自动生成的测试。让我们更新它以匹配我们的应用程序逻辑。

test/mailers/user_mailer_test.rb 更改为以下内容

require "test_helper"

class UserMailerTest < ActionMailer::TestCase
  test "email_confirmation" do
    user = users(:one)
    user.update(unconfirmed_email: "new@example.org")
    mail = UserMailer.with(user: user).email_confirmation
    assert_equal "Email confirmation", mail.subject
    assert_equal [ "new@example.org" ], mail.to
    assert_match "/email/confirmations/", mail.body.encoded
  end
end

此测试确保用户有一个 unconfirmed_email,并且电子邮件已发送到该电子邮件地址。它还确保电子邮件正文包含 /email/confirmations 的路径,以便我们知道它包含用户点击并确认其新电子邮件地址的链接。

10.4. 测试设置

我们应该测试的另一个领域是“设置”导航。我们希望确保适当的链接对管理员可见,而对普通用户不可见。

首先,让我们在 test/fixtures/users.yml 中创建一个管理员用户夹具,并为夹具添加名称,以便它们通过验证。

<% password_digest = BCrypt::Password.create("password") %>

one:
  email_address: one@example.com
  password_digest: <%= password_digest %>
  first_name: User
  last_name: One

two:
  email_address: two@example.com
  password_digest: <%= password_digest %>
  first_name: User
  last_name: Two

admin:
  email_address: admin@example.com
  password_digest: <%= password_digest %>
  first_name: Admin
  last_name: User
  admin: true

然后,在 test/integration/settings_test.rb 中为此创建一个测试文件。

require "test_helper"

class SettingsTest < ActionDispatch::IntegrationTest
  test "user settings nav" do
    sign_in_as users(:one)
    get settings_profile_path
    assert_dom "h4", "Account Settings"
    assert_not_dom "a", "Store Settings"
  end

  test "admin settings nav" do
    sign_in_as users(:admin)
    get settings_profile_path
    assert_dom "h4", "Account Settings"
    assert_dom "h4", "Store Settings"
  end
end

这些测试确保只有管理员才能在导航栏中看到“商店设置”。

您可以使用以下命令运行这些测试

$ bin/rails test test/integration/settings_test.rb

我们还希望确保普通用户无法访问“产品”和“用户”的“商店设置”。让我们为此添加一些测试。

test "regular user cannot access /store/products" do
  sign_in_as users(:one)
  get store_products_path
  assert_response :redirect
  assert_equal "You aren't allowed to do that.", flash[:alert]
end

test "regular user cannot access /store/users" do
  sign_in_as users(:one)
  get store_users_path
  assert_response :redirect
  assert_equal "You aren't allowed to do that.", flash[:alert]
end

这些测试使用普通用户访问仅限管理员的区域,并确保他们被重定向并显示闪存消息。

让我们通过确保管理员用户可以访问这些区域来完成这些测试。

test "admins can access /store/products" do
  sign_in_as users(:admin)
  get store_products_path
  assert_response :success
end

test "admins can access /store/users" do
  sign_in_as users(:admin)
  get store_users_path
  assert_response :success
end

再次运行测试文件,您应该会看到它们都通过了。

$ bin/rails test test/integration/settings_test.rb
Running 6 tests in a single process (parallelization threshold is 50)
Run options: --seed 33354

# Running:

......

Finished in 0.625542s, 9.5917 runs/s, 12.7889 assertions/s.
6 runs, 8 assertions, 0 failures, 0 errors, 0 skips

然后,我们再次运行完整的测试套件,以确保所有测试都通过。

$ bin/rails test
Running 18 tests in a single process (parallelization threshold is 50)
Run options: --seed 38561

# Running:

..................

Finished in 0.915621s, 19.6588 runs/s, 51.3313 assertions/s.
18 runs, 47 assertions, 0 failures, 0 errors, 0 skips

太棒了!现在,让我们将其部署到生产环境。

11. 部署到生产环境

由于我们之前在《入门指南》中设置了 Kamal,我们只需将代码更改推送到我们的 Git 仓库并运行

$ bin/kamal deploy

这将为我们的应用程序构建一个新的容器并将其部署到我们的生产服务器。

11.1. 在生产环境中设置管理员

如果您添加了 attr_readonly :admin,您将需要使用 dbconsole 来更新您的账户。

$ bin/kamal dbc
UPDATE users SET admin=true WHERE users.email='you@example.org';
.quit

否则,您可以使用 Rails 控制台来更新您的账户。

$ bin/kamal console
irb> User.find_by(email: "you@example.org").update(admin: true)

您现在可以使用您的账户访问生产环境中的“商店设置”。

12. 下一步

您做到了!您的电子商务商店现在支持用户注册、账户管理以及用于管理产品和用户的管理区域。

接下来,请按照《愿望清单教程》继续学习。

祝您构建愉快!

返回所有教程



回到顶部