From a54ebdbf2350af8771cee89b0a34be2a29daaf7d Mon Sep 17 00:00:00 2001 From: songtianlun Date: Mon, 6 Jan 2025 18:38:39 +0800 Subject: [PATCH] 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 --- .../account_activations_controller.rb | 15 ++++++++ app/controllers/sessions_controller.rb | 17 ++++++--- app/controllers/users_controller.rb | 14 +++++--- app/helpers/account_activations_helper.rb | 2 ++ app/helpers/sessions_helper.rb | 2 +- app/mailers/application_mailer.rb | 2 +- app/mailers/user_mailer.rb | 22 ++++++++++++ app/models/user.rb | 36 ++++++++++++++++--- .../user_mailer/account_activation.html.erb | 15 ++++++++ .../user_mailer/account_activation.text.erb | 5 +++ app/views/user_mailer/password_reset.html.erb | 5 +++ app/views/user_mailer/password_reset.text.erb | 3 ++ config/routes.rb | 1 + .../20250106091017_add_activation_to_users.rb | 7 ++++ db/schema.rb | 5 ++- db/seeds.rb | 8 +++-- .../account_activations_controller_test.rb | 7 ++++ test/fixtures/users.yml | 17 ++++++--- test/integration/user_show_test.rb | 20 +++++++++++ test/integration/users_index_test.rb | 2 ++ test/integration/users_signup_test.rb | 36 +++++++++++++++---- test/mailers/previews/user_mailer_preview.rb | 14 ++++++++ test/mailers/user_mailer_test.rb | 23 ++++++++++++ test/models/user_test.rb | 2 +- 24 files changed, 249 insertions(+), 31 deletions(-) create mode 100644 app/controllers/account_activations_controller.rb create mode 100644 app/helpers/account_activations_helper.rb create mode 100644 app/mailers/user_mailer.rb create mode 100644 app/views/user_mailer/account_activation.html.erb create mode 100644 app/views/user_mailer/account_activation.text.erb create mode 100644 app/views/user_mailer/password_reset.html.erb create mode 100644 app/views/user_mailer/password_reset.text.erb create mode 100644 db/migrate/20250106091017_add_activation_to_users.rb create mode 100644 test/controllers/account_activations_controller_test.rb create mode 100644 test/integration/user_show_test.rb create mode 100644 test/mailers/previews/user_mailer_preview.rb create mode 100644 test/mailers/user_mailer_test.rb diff --git a/app/controllers/account_activations_controller.rb b/app/controllers/account_activations_controller.rb new file mode 100644 index 0000000..b0ded3d --- /dev/null +++ b/app/controllers/account_activations_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5435eae..3a338f9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -7,11 +7,18 @@ class SessionsController < ApplicationController user = User.find_by(email: params[:session][:email].downcase) # if user && user.authenticate(params[:session][:password]) if user&.authenticate(params[:session][:password]) - forwarding_url = session[:forwarding_url] - reset_session - params[:session][:remember_me] == "1" ? remember(user) : forget(user) - log_in user - redirect_to forwarding_url || user + if user.activated? + forwarding_url = session[:forwarding_url] + reset_session + params[:session][:remember_me] == "1" ? remember(user) : forget(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 flash.now[:danger] = "Invalid email/password combination" render "new", status: :unprocessable_entity diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index cb9b88d..edc7eee 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -7,11 +7,12 @@ class UsersController < ApplicationController def index # @users = User.all # @users = User.order(:name).page(params[:page]) - @users = User.page(params[:page]) + @users = User.where(activated: true).page(params[:page]) end def show @user = User.find(params[:id]) + redirect_to root_url and return unless @user.activated? # debugger end def new @@ -22,11 +23,14 @@ class UsersController < ApplicationController def create @user = User.new(user_params) if @user.save - reset_session - log_in @user - flash[:success] = "Welcome to the Sample App!" - redirect_to @user + # reset_session + # log_in @user + # flash[:success] = "Welcome to the Sample App!" + # redirect_to @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 render "new" end diff --git a/app/helpers/account_activations_helper.rb b/app/helpers/account_activations_helper.rb new file mode 100644 index 0000000..c4d5ac7 --- /dev/null +++ b/app/helpers/account_activations_helper.rb @@ -0,0 +1,2 @@ +module AccountActivationsHelper +end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index a469834..a7b99a0 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -19,7 +19,7 @@ module SessionsHelper end elsif (user_id = cookies.encrypted[: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 @current_user = user end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..d125c4a 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" + default from: "noreply@mail.frytea.com" layout "mailer" end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..e0ff16a --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 1f45758..25343b9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,9 @@ class User < ApplicationRecord - attr_accessor :remember_token + attr_accessor :remember_token, :activation_token # 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 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, @@ -44,12 +46,36 @@ class User < ApplicationRecord end end - def authenticated?(remember_token) - return false if remember_digest.nil? - BCrypt::Password.new(remember_digest).is_password?(remember_token) + # powered by metaprogramming + def authenticated?(attribute, token) + digest = send("#{attribute}_digest") + return false if digest.nil? + BCrypt::Password.new(digest).is_password?(token) end def forget update_attribute(:remember_digest, nil) 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 diff --git a/app/views/user_mailer/account_activation.html.erb b/app/views/user_mailer/account_activation.html.erb new file mode 100644 index 0000000..e79ba94 --- /dev/null +++ b/app/views/user_mailer/account_activation.html.erb @@ -0,0 +1,15 @@ +

Sample App

+ +

Hi <%= @user.name %>

+ +

+ Welcome to the Sample App! Click on the link below to activate your account: +

+ +<%= link_to "Activate", edit_account_activation_url(@user.activation_token, + email:@user.email) %> + +

+ <%= edit_account_activation_url(@user.activation_token, + email:@user.email) %> +

\ No newline at end of file diff --git a/app/views/user_mailer/account_activation.text.erb b/app/views/user_mailer/account_activation.text.erb new file mode 100644 index 0000000..d73c5a5 --- /dev/null +++ b/app/views/user_mailer/account_activation.text.erb @@ -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) %> \ No newline at end of file diff --git a/app/views/user_mailer/password_reset.html.erb b/app/views/user_mailer/password_reset.html.erb new file mode 100644 index 0000000..6dec984 --- /dev/null +++ b/app/views/user_mailer/password_reset.html.erb @@ -0,0 +1,5 @@ +

User#password_reset

+ +

+ <%= @greeting %>, find me in app/views/user_mailer/password_reset.html.erb +

diff --git a/app/views/user_mailer/password_reset.text.erb b/app/views/user_mailer/password_reset.text.erb new file mode 100644 index 0000000..5ba80cc --- /dev/null +++ b/app/views/user_mailer/password_reset.text.erb @@ -0,0 +1,3 @@ +User#password_reset + +<%= @greeting %>, find me in app/views/user_mailer/password_reset.text.erb diff --git a/config/routes.rb b/config/routes.rb index 6448878..cbfc239 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,7 @@ Rails.application.routes.draw do delete "/logout", to: "sessions#destroy" resources :users + resources :account_activations, only: [:edit] # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html diff --git a/db/migrate/20250106091017_add_activation_to_users.rb b/db/migrate/20250106091017_add_activation_to_users.rb new file mode 100644 index 0000000..be38e38 --- /dev/null +++ b/db/migrate/20250106091017_add_activation_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 93b2f85..991e966 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.string "name" t.string "email" @@ -19,6 +19,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_05_095157) do t.string "password_digest" t.string "remember_digest" 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 end end diff --git a/db/seeds.rb b/db/seeds.rb index 4c18990..ec6fc32 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -12,7 +12,9 @@ User.create!(name: "Example User", email: "example@example.com", password: "foobar", password_confirmation: "foobar", - admin: true) + admin: true, + activated: true, + activated_at: Time.zone.now) 99.times do |n| name = Faker::Name.name @@ -21,5 +23,7 @@ User.create!(name: "Example User", User.create!(name: name, email: email, password: password, - password_confirmation: password) + password_confirmation: password, + activated: true, + activated_at: Time.zone.now) end diff --git a/test/controllers/account_activations_controller_test.rb b/test/controllers/account_activations_controller_test.rb new file mode 100644 index 0000000..bcd2199 --- /dev/null +++ b/test/controllers/account_activations_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class AccountActivationsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 7944493..953d04c 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,23 +1,32 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -one: - name: MyString - email: MyString - michael: name: Michael Example email: michael@example.com admin: true 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: name: Sterling Archer email: suchess@example.gov password_digest: <%= User.digest('password') %> + activated: true + activated_at: <%= Time.zone.now %> <% 30.times do |n| %> user_<%= n %>: name: <%= "user #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> + activated: true + activated_at: <%= Time.zone.now %> <% end %> \ No newline at end of file diff --git a/test/integration/user_show_test.rb b/test/integration/user_show_test.rb new file mode 100644 index 0000000..35953b5 --- /dev/null +++ b/test/integration/user_show_test.rb @@ -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 diff --git a/test/integration/users_index_test.rb b/test/integration/users_index_test.rb index 5b11830..363a64b 100644 --- a/test/integration/users_index_test.rb +++ b/test/integration/users_index_test.rb @@ -13,7 +13,9 @@ class UsersIndexTest < ActionDispatch::IntegrationTest assert_select "ul.pagination" first_page_of_users = User.page(1) + first_page_of_users.first.toggle!(:activated) first_page_of_users.each do |user| + # assert_not user.activated? assert_select "a[href=?]", user_path(user), text: user.name unless user == @admin assert_select "a[href=?]", user_path(user), text: "delete" diff --git a/test/integration/users_signup_test.rb b/test/integration/users_signup_test.rb index a22ba93..a45e991 100644 --- a/test/integration/users_signup_test.rb +++ b/test/integration/users_signup_test.rb @@ -1,6 +1,11 @@ require "test_helper" class UsersSignupTest < ActionDispatch::IntegrationTest + + def setup + ActionMailer::Base.deliveries.clear + end + test "invalid signup information" do get signup_path assert_no_difference "User.count" do @@ -14,17 +19,36 @@ class UsersSignupTest < ActionDispatch::IntegrationTest assert_select "div.alert-danger" end - test "valid signup information" do + test "valid signup information with account activation" do get signup_path assert_difference "User.count", 1 do post users_path, params: { user: { name: "Example User", - email: "user@example.com", - password: "password", - password_confirmation: "password" } } + email: "user@example.com", + password: "password", + password_confirmation: "password" } } 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! - assert_template "users/show" - assert_not flash.notice + assert_template 'users/show' assert is_logged_in? end end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 0000000..9c70a0d --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -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 diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 0000000..8adf3eb --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -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 diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 4c34345..0f46c09 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -75,6 +75,6 @@ class UserTest < ActiveSupport::TestCase end test "authenticated? should return false for a user with nil digest" do - assert_not @user.authenticated?("") + assert_not @user.authenticated?(:remember, "") end end