feat: add account activation feature

- Implement AccountActivationsController for activation logic
- Create UserMailer for sending activation emails
- Update SessionsController to handle unactivated users
- Modify UsersController to restrict access to activated users
- Add activation fields to User model and database migration
- Create views for account activation emails
- Add tests for account activation functionality
This commit is contained in:
songtianlun 2025-01-06 18:38:39 +08:00
parent 286ca3419f
commit a54ebdbf23
24 changed files with 249 additions and 31 deletions

View File

@ -0,0 +1,15 @@
class AccountActivationsController < ApplicationController
include SessionsHelper
def edit
user = User.find_by(email: params[:email])
if user && !user.activated? && user.authenticated?(:activation, params[:id])
user.send(:activate)
log_in user
flash[:success] = "Account activated!"
redirect_to user
else
flash[:danger] = "Invalid activation link"
redirect_to root_url
end
end
end

View File

@ -7,11 +7,18 @@ class SessionsController < ApplicationController
user = User.find_by(email: params[:session][:email].downcase) user = User.find_by(email: params[:session][:email].downcase)
# if user && user.authenticate(params[:session][:password]) # if user && user.authenticate(params[:session][:password])
if user&.authenticate(params[:session][:password]) if user&.authenticate(params[:session][:password])
forwarding_url = session[:forwarding_url] if user.activated?
reset_session forwarding_url = session[:forwarding_url]
params[:session][:remember_me] == "1" ? remember(user) : forget(user) reset_session
log_in user params[:session][:remember_me] == "1" ? remember(user) : forget(user)
redirect_to forwarding_url || user log_in user
redirect_to forwarding_url || user
else
message = "Account not activated. "
message += "Check your email for the activation link."
flash[:danger] = message
redirect_to root_url
end
else else
flash.now[:danger] = "Invalid email/password combination" flash.now[:danger] = "Invalid email/password combination"
render "new", status: :unprocessable_entity render "new", status: :unprocessable_entity

View File

@ -7,11 +7,12 @@ class UsersController < ApplicationController
def index def index
# @users = User.all # @users = User.all
# @users = User.order(:name).page(params[:page]) # @users = User.order(:name).page(params[:page])
@users = User.page(params[:page]) @users = User.where(activated: true).page(params[:page])
end end
def show def show
@user = User.find(params[:id]) @user = User.find(params[:id])
redirect_to root_url and return unless @user.activated?
# debugger # debugger
end end
def new def new
@ -22,11 +23,14 @@ class UsersController < ApplicationController
def create def create
@user = User.new(user_params) @user = User.new(user_params)
if @user.save if @user.save
reset_session # reset_session
log_in @user # log_in @user
flash[:success] = "Welcome to the Sample App!" # flash[:success] = "Welcome to the Sample App!"
redirect_to @user # redirect_to @user
# redirect_to user_url(@user) # redirect_to user_url(@user)
@user.send(:send_activation_email)
flash[:info] = "Please check your email to activate your account."
redirect_to root_url
else else
render "new" render "new"
end end

View File

@ -0,0 +1,2 @@
module AccountActivationsHelper
end

View File

@ -19,7 +19,7 @@ module SessionsHelper
end end
elsif (user_id = cookies.encrypted[:user_id]) elsif (user_id = cookies.encrypted[:user_id])
user = User.find_by(id: user_id) user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token]) if user && user.authenticated?(:remember, cookies[:remember_token])
log_in user log_in user
@current_user = user @current_user = user
end end

View File

@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: "from@example.com" default from: "noreply@mail.frytea.com"
layout "mailer" layout "mailer"
end end

View File

@ -0,0 +1,22 @@
class UserMailer < ApplicationMailer
# Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup:
#
# en.user_mailer.account_activation.subject
#
def account_activation(user)
@user = user
mail to: user.email, subject: "Account activation"
end
# Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup:
#
# en.user_mailer.password_reset.subject
#
def password_reset
@greeting = "Hi"
mail to: "to@example.org"
end
end

View File

@ -1,7 +1,9 @@
class User < ApplicationRecord class User < ApplicationRecord
attr_accessor :remember_token attr_accessor :remember_token, :activation_token
# before_save { self.email = email.downcase } # before_save { self.email = email.downcase }
before_save { email.downcase! } # before_save { email.downcase! }
before_save :downcase_email
before_create :create_activation_digest
validates :name, presence: true, length: { maximum: 50 } validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 }, validates :email, presence: true, length: { maximum: 255 },
@ -44,12 +46,36 @@ class User < ApplicationRecord
end end
end end
def authenticated?(remember_token) # powered by metaprogramming
return false if remember_digest.nil? def authenticated?(attribute, token)
BCrypt::Password.new(remember_digest).is_password?(remember_token) digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end end
def forget def forget
update_attribute(:remember_digest, nil) update_attribute(:remember_digest, nil)
end end
private
def downcase_email
# self.email = email.downcase
email.downcase!
end
def create_activation_digest
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
end
def activate
# update_attribute(:activated, true)
# update_attribute(:activated_at, Time.zone.now)
update_columns(activated: true, activated_at: Time.zone.now)
end
def send_activation_email
UserMailer.account_activation(self).deliver_now
end
end end

View File

@ -0,0 +1,15 @@
<h1>Sample App</h1>
<p>Hi <%= @user.name %></p>
<p>
Welcome to the Sample App! Click on the link below to activate your account:
</p>
<%= link_to "Activate", edit_account_activation_url(@user.activation_token,
email:@user.email) %>
<p>
<%= edit_account_activation_url(@user.activation_token,
email:@user.email) %>
</p>

View File

@ -0,0 +1,5 @@
Hi <%= @user.name %>
Welcome to the Sample App! Click on the link below to activate your account:
<%= edit_account_activation_url(@user.activation_token, email: @user.email) %>

View File

@ -0,0 +1,5 @@
<h1>User#password_reset</h1>
<p>
<%= @greeting %>, find me in app/views/user_mailer/password_reset.html.erb
</p>

View File

@ -0,0 +1,3 @@
User#password_reset
<%= @greeting %>, find me in app/views/user_mailer/password_reset.text.erb

View File

@ -18,6 +18,7 @@ Rails.application.routes.draw do
delete "/logout", to: "sessions#destroy" delete "/logout", to: "sessions#destroy"
resources :users resources :users
resources :account_activations, only: [:edit]
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

View File

@ -0,0 +1,7 @@
class AddActivationToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :activation_digest, :string
add_column :users, :activated, :boolean, default: false
add_column :users, :activated_at, :datetime
end
end

5
db/schema.rb generated
View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_05_095157) do ActiveRecord::Schema[8.0].define(version: 2025_01_06_091017) do
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.string "name" t.string "name"
t.string "email" t.string "email"
@ -19,6 +19,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_05_095157) do
t.string "password_digest" t.string "password_digest"
t.string "remember_digest" t.string "remember_digest"
t.boolean "admin", default: false t.boolean "admin", default: false
t.string "activation_digest"
t.boolean "activated", default: false
t.datetime "activated_at"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
end end
end end

View File

@ -12,7 +12,9 @@ User.create!(name: "Example User",
email: "example@example.com", email: "example@example.com",
password: "foobar", password: "foobar",
password_confirmation: "foobar", password_confirmation: "foobar",
admin: true) admin: true,
activated: true,
activated_at: Time.zone.now)
99.times do |n| 99.times do |n|
name = Faker::Name.name name = Faker::Name.name
@ -21,5 +23,7 @@ User.create!(name: "Example User",
User.create!(name: name, User.create!(name: name,
email: email, email: email,
password: password, password: password,
password_confirmation: password) password_confirmation: password,
activated: true,
activated_at: Time.zone.now)
end end

View File

@ -0,0 +1,7 @@
require "test_helper"
class AccountActivationsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@ -1,23 +1,32 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
email: MyString
michael: michael:
name: Michael Example name: Michael Example
email: michael@example.com email: michael@example.com
admin: true admin: true
password_digest: <%= User.digest('password') %> password_digest: <%= User.digest('password') %>
activated: true
activated_at: <%= Time.zone.now %>
inactive:
name: Inactive User
email: inactive@example.com
admin: false
password_digest: <%= User.digest('password') %>
activated: false
archer: archer:
name: Sterling Archer name: Sterling Archer
email: suchess@example.gov email: suchess@example.gov
password_digest: <%= User.digest('password') %> password_digest: <%= User.digest('password') %>
activated: true
activated_at: <%= Time.zone.now %>
<% 30.times do |n| %> <% 30.times do |n| %>
user_<%= n %>: user_<%= n %>:
name: <%= "user #{n}" %> name: <%= "user #{n}" %>
email: <%= "user-#{n}@example.com" %> email: <%= "user-#{n}@example.com" %>
password_digest: <%= User.digest('password') %> password_digest: <%= User.digest('password') %>
activated: true
activated_at: <%= Time.zone.now %>
<% end %> <% end %>

View File

@ -0,0 +1,20 @@
require "test_helper"
class UserShowTest < ActionDispatch::IntegrationTest
def setup
@inactive_user = users(:inactive)
@active_user = users(:archer)
end
test "should redirect when user not activated" do
get user_path(@inactive_user)
assert_response :redirect
assert_redirected_to root_url
end
test "should display user when activated" do
get user_path(@active_user)
assert_response :success
assert_template 'users/show'
end
end

View File

@ -13,7 +13,9 @@ class UsersIndexTest < ActionDispatch::IntegrationTest
assert_select "ul.pagination" assert_select "ul.pagination"
first_page_of_users = User.page(1) first_page_of_users = User.page(1)
first_page_of_users.first.toggle!(:activated)
first_page_of_users.each do |user| first_page_of_users.each do |user|
# assert_not user.activated?
assert_select "a[href=?]", user_path(user), text: user.name assert_select "a[href=?]", user_path(user), text: user.name
unless user == @admin unless user == @admin
assert_select "a[href=?]", user_path(user), text: "delete" assert_select "a[href=?]", user_path(user), text: "delete"

View File

@ -1,6 +1,11 @@
require "test_helper" require "test_helper"
class UsersSignupTest < ActionDispatch::IntegrationTest class UsersSignupTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
end
test "invalid signup information" do test "invalid signup information" do
get signup_path get signup_path
assert_no_difference "User.count" do assert_no_difference "User.count" do
@ -14,17 +19,36 @@ class UsersSignupTest < ActionDispatch::IntegrationTest
assert_select "div.alert-danger" assert_select "div.alert-danger"
end end
test "valid signup information" do test "valid signup information with account activation" do
get signup_path get signup_path
assert_difference "User.count", 1 do assert_difference "User.count", 1 do
post users_path, params: { user: { name: "Example User", post users_path, params: { user: { name: "Example User",
email: "user@example.com", email: "user@example.com",
password: "password", password: "password",
password_confirmation: "password" } } password_confirmation: "password" } }
end end
# follow_redirect!
# assert_template "users/show"
# assert_not flash.notice
# assert is_logged_in?
assert_equal 1, ActionMailer::Base.deliveries.size
user = assigns(:user)
assert_not user.activated?
# 尝试激活前登陆
log_in_as(user)
assert_not is_logged_in?
# 激活令牌无效
get edit_account_activation_path("invalid token", email: user.email)
assert_not is_logged_in?
# 令牌有效,邮箱错误
get edit_account_activation_path(user.activation_token, email: 'wrong')
assert_not is_logged_in?
# 令牌有效
get edit_account_activation_path(user.activation_token, email: user.email)
assert user.reload.activated?
follow_redirect! follow_redirect!
assert_template "users/show" assert_template 'users/show'
assert_not flash.notice
assert is_logged_in? assert is_logged_in?
end end
end end

View File

@ -0,0 +1,14 @@
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/account_activation
def account_activation
user = User.first
user.activation_token = User.new_token
UserMailer.account_activation(user)
end
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/password_reset
def password_reset
UserMailer.password_reset
end
end

View File

@ -0,0 +1,23 @@
require "test_helper"
class UserMailerTest < ActionMailer::TestCase
test "account_activation" do
user = users(:michael)
user.activation_token = User.new_token
mail = UserMailer.account_activation(user)
assert_equal "Account activation", mail.subject
assert_equal [ user.email ], mail.to
assert_equal [ "noreply@mail.frytea.com" ], mail.from
assert_match user.name, mail.body.encoded
assert_match user.activation_token, mail.body.encoded
assert_match CGI.escape(user.email), mail.body.encoded
end
test "password_reset" do
mail = UserMailer.password_reset
assert_equal "Password reset", mail.subject
assert_equal [ "to@example.org" ], mail.to
assert_equal [ "noreply@mail.frytea.com" ], mail.from
assert_match "Hi", mail.body.encoded
end
end

View File

@ -75,6 +75,6 @@ class UserTest < ActiveSupport::TestCase
end end
test "authenticated? should return false for a user with nil digest" do test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?("") assert_not @user.authenticated?(:remember, "")
end end
end end