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:
parent
286ca3419f
commit
a54ebdbf23
15
app/controllers/account_activations_controller.rb
Normal file
15
app/controllers/account_activations_controller.rb
Normal 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
|
@ -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
|
||||||
|
@ -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
|
||||||
|
2
app/helpers/account_activations_helper.rb
Normal file
2
app/helpers/account_activations_helper.rb
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
module AccountActivationsHelper
|
||||||
|
end
|
@ -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
|
||||||
|
@ -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
|
||||||
|
22
app/mailers/user_mailer.rb
Normal file
22
app/mailers/user_mailer.rb
Normal 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
|
@ -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
|
||||||
|
15
app/views/user_mailer/account_activation.html.erb
Normal file
15
app/views/user_mailer/account_activation.html.erb
Normal 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>
|
5
app/views/user_mailer/account_activation.text.erb
Normal file
5
app/views/user_mailer/account_activation.text.erb
Normal 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) %>
|
5
app/views/user_mailer/password_reset.html.erb
Normal file
5
app/views/user_mailer/password_reset.html.erb
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<h1>User#password_reset</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= @greeting %>, find me in app/views/user_mailer/password_reset.html.erb
|
||||||
|
</p>
|
3
app/views/user_mailer/password_reset.text.erb
Normal file
3
app/views/user_mailer/password_reset.text.erb
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
User#password_reset
|
||||||
|
|
||||||
|
<%= @greeting %>, find me in app/views/user_mailer/password_reset.text.erb
|
@ -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
|
||||||
|
|
||||||
|
7
db/migrate/20250106091017_add_activation_to_users.rb
Normal file
7
db/migrate/20250106091017_add_activation_to_users.rb
Normal 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
5
db/schema.rb
generated
@ -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
|
||||||
|
@ -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
|
||||||
|
7
test/controllers/account_activations_controller_test.rb
Normal file
7
test/controllers/account_activations_controller_test.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class AccountActivationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
17
test/fixtures/users.yml
vendored
17
test/fixtures/users.yml
vendored
@ -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 %>
|
20
test/integration/user_show_test.rb
Normal file
20
test/integration/user_show_test.rb
Normal 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
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
14
test/mailers/previews/user_mailer_preview.rb
Normal file
14
test/mailers/previews/user_mailer_preview.rb
Normal 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
|
23
test/mailers/user_mailer_test.rb
Normal file
23
test/mailers/user_mailer_test.rb
Normal 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
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user