Email confirmation workflow

This commit is contained in:
Trevor Vallender 2023-08-19 15:38:38 +01:00
parent c57fe587c2
commit 107c2e62a8
21 changed files with 160 additions and 39 deletions

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class EmailConfirmationsController < ApplicationController
def confirm
@user = User.find_by(email: params[:email])
if params[:confirmation_string] == @user.email_confirmation_string
@user.update(email_confirmation_string: nil)
redirect_to new_session_path, notice: t(".email_confirmed")
else
redirect_to root_path, notice: t(".email_confirmation_failed")
end
end
end

View File

@ -1,11 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class SessionsController < ApplicationController class SessionsController < ApplicationController
before_action :set_user, only: [:create]
before_action :ensure_email_confirmed, only: [:create]
def new; end def new; end
def create def create
@user = User.find_by(username: params[:username])
if @user.present? && @user.authenticate(params[:password]) if @user.present? && @user.authenticate(params[:password])
session[:user_id] = @user.id session[:user_id] = @user.id
redirect_to @user, notice: t(".logged_in") redirect_to @user, notice: t(".logged_in")
@ -19,4 +20,17 @@ class SessionsController < ApplicationController
reset_session reset_session
redirect_to root_path, notice: t(".logged_out") redirect_to root_path, notice: t(".logged_out")
end end
private
def set_user
@user = User.find_by(username: params[:username])
end
def ensure_email_confirmed
return unless @user.requires_confirmation?
flash.alert = t(".account_not_confirmed")
redirect_to new_session_path
end
end end

View File

@ -10,7 +10,6 @@ class UsersController < ApplicationController
def create def create
@user = User.new(user_params) @user = User.new(user_params)
if @user.save if @user.save
# TODO: Add email confirmation workflow
session[:user_id] = @user.id session[:user_id] = @user.id
redirect_to root_path, notice: t(".account_created") redirect_to root_path, notice: t(".account_created")
else else

View File

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

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class UserMailer < ApplicationMailer
def confirm_email
@user = params[:user]
mail(to: @user.email, subject: t(".confirm_email"))
end
end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "securerandom"
class User < ApplicationRecord class User < ApplicationRecord
has_secure_password has_secure_password
@ -26,6 +28,8 @@ class User < ApplicationRecord
presence: true, presence: true,
if: :validate_password? if: :validate_password?
after_create :require_confirmation
def to_param def to_param
username username
end end
@ -34,8 +38,17 @@ class User < ApplicationRecord
"#{first_name} #{last_names}" "#{first_name} #{last_names}"
end end
def requires_confirmation?
email_confirmation_string.present?
end
private private
def require_confirmation
update(email_confirmation_string: SecureRandom.uuid)
UserMailer.with(user: self).confirm_email.deliver_later
end
def validate_password? def validate_password?
new_record? || password_digest_changed? new_record? || password_digest_changed?
end end

View File

@ -0,0 +1,10 @@
Hi,
Please visit the following link to confirm your email and access your new
account at Summon Player:
<%= confirm_email_url(email: @user.email, confirmation_string: @user.email_confirmation_string) %>
Thanks,
The Summon Player team.

View File

@ -3,20 +3,9 @@
require "active_support/core_ext/integer/time" require "active_support/core_ext/integer/time"
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# In the development environment your application's code is reloaded any time
# it changes. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
config.cache_classes = false config.cache_classes = false
# Do not eager load code on boot.
config.eager_load = false config.eager_load = false
# Show full error reports.
config.consider_all_requests_local = true config.consider_all_requests_local = true
# Enable server timing
config.server_timing = true config.server_timing = true
# Enable/disable caching. By default caching is disabled. # Enable/disable caching. By default caching is disabled.
@ -27,7 +16,7 @@ Rails.application.configure do
config.cache_store = :memory_store config.cache_store = :memory_store
config.public_file_server.headers = { config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{2.days.to_i}" "Cache-Control" => "public, max-age=#{2.days.to_i}",
} }
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
@ -35,38 +24,30 @@ Rails.application.configure do
config.cache_store = :null_store config.cache_store = :null_store
end end
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local config.active_storage.service = :local
# Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = true
config.action_mailer.raise_delivery_errors = false
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log config.active_support.deprecation = :log
# Raise exceptions for disallowed deprecations.
config.active_support.disallowed_deprecation = :raise config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
config.active_support.disallowed_deprecation_warnings = [] config.active_support.disallowed_deprecation_warnings = []
# Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load config.active_record.migration_error = :page_load
# Highlight code that triggered database queries in logs.
config.active_record.verbose_query_logs = true config.active_record.verbose_query_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = true config.assets.quiet = true
# Raises error for missing translations. # Raises error for missing translations.
config.i18n.raise_on_missing_translations = true config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names. # Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true config.action_view.annotate_rendered_view_with_filenames = true
# Uncomment if you wish to allow Action Cable access from any origin. # Use Mailhog
# config.action_cable.disable_request_forgery_protection = true config.action_mailer.perform_deliveries = true
config.action_mailer.smtp_settings = {
address: "localhost",
port: 1025,
}
end end

View File

@ -0,0 +1,4 @@
en:
email_confirmations:
email_confirmed: Thanks, your email has been confirmed.
email_confirmation_failed: Sorry, we could not confirm your email.

View File

@ -3,3 +3,4 @@ en:
logged_in: Youve been logged in. logged_in: Youve been logged in.
login_fail: Sorry, we couldnt log you in. login_fail: Sorry, we couldnt log you in.
logged_out: You have been logged out. logged_out: You have been logged out.
account_not_confirmed: Please confirm your email to log in.

View File

@ -1,6 +1,6 @@
en: en:
users: users:
create_failed: Could not create user account, please correct the errors below. create_failed: Could not create user account, please correct the errors below.
account_created: Account created! account_created: Account created! Please check your email to confirm.
account_updated: Your details have been updated. account_updated: Your details have been updated.
update_failed: Could not update your details. update_failed: Could not update your details.

View File

@ -0,0 +1,3 @@
en:
user_mailer:
confirm_email: Please confirm your email.

View File

@ -1,9 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
Rails.application.routes.draw do Rails.application.routes.draw do
default_url_options :host => "summonplayer.com"
root "sessions#new" root "sessions#new"
resources :users, only: [:new, :create, :show, :edit, :update] resources :users, only: [:new, :create, :show, :edit, :update]
resources :sessions, only: [:new, :create] resources :sessions, only: [:new, :create]
delete "log_out", to: "sessions#destroy_session" delete "log_out", to: "sessions#destroy_session"
get "confirm_email", to: "email_confirmations#confirm"
end end

View File

@ -0,0 +1,5 @@
class AddEmailConfirmationStringToUser < ActiveRecord::Migration[7.0]
def change
add_column :users, :email_confirmation_string, :uuid, default: nil
end
end

3
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[7.0].define(version: 2023_08_08_190158) do ActiveRecord::Schema[7.0].define(version: 2023_08_18_201128) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -22,6 +22,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_08_190158) do
t.string "email", null: false t.string "email", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.uuid "email_confirmation_string"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["username"], name: "index_users_on_username", unique: true t.index ["username"], name: "index_users_on_username", unique: true
end end

View File

@ -25,4 +25,8 @@
CREATE ROLE summon_player WITH LOGIN PASSWORD 'postgres' SUPERUSER; CREATE ROLE summon_player WITH LOGIN PASSWORD 'postgres' SUPERUSER;
''; '';
}; };
services.mailhog = {
enable = true;
};
} }

View File

@ -0,0 +1,16 @@
require "test_helper"
class EmailConfirmationsControllerTest < ActionDispatch::IntegrationTest
test "should confirm user account" do
user = users(:unconfirmed_user)
assert user.requires_confirmation?
get confirm_email_url(
email: user.email,
confirmation_string: user.email_confirmation_string,
)
follow_redirect!
assert_includes @response.body, I18n.t("email_confirmations.email_confirmed")
user.reload
assert_not user.requires_confirmation?
end
end

View File

@ -4,3 +4,12 @@ user:
first_name: Gimli first_name: Gimli
last_names: son of Glóin last_names: son of Glóin
email: gimli@example.com email: gimli@example.com
unconfirmed_user:
username: pippin
password_digest: <%= BCrypt::Password.create('tolkien-abercrombie-hobb-barker', cost: 5) %>
first_name: Peregrin
last_names: Took
email: pippin@example.com
email_confirmation_string: 5a68514b-7cd2-4ac8-bec1-22c2526f9a52

View File

@ -1,9 +1,38 @@
# frozen_string_literal: true
require "test_helper" require "test_helper"
class UserTest < ActiveSupport::TestCase class UserTest < ActiveSupport::TestCase
include ActionMailer::TestHelper
test "email must resemble an email" do test "email must resemble an email" do
user = users(:user) user = users(:user)
user.email = "foobar" user.email = "foobar"
assert_not user.valid? assert_not user.valid?
end end
test "a new user requires confirmation" do
user = User.create(
username: "gorkamorka",
email: "gorka@morka.com",
first_name: "Ork",
last_names: "Boyz",
password: "snotlings-are-for-squashing",
password_confirmation: "snotlings-are-for-squashing",
)
assert user.requires_confirmation?
end
test "a new user requests confirmation" do
assert_emails 1 do
User.create(
username: "gorkamorka",
email: "gorka@morka.com",
first_name: "Ork",
last_names: "Boyz",
password: "snotlings-are-for-squashing",
password_confirmation: "snotlings-are-for-squashing",
)
end
end
end end

View File

@ -3,17 +3,23 @@
require "application_system_test_case" require "application_system_test_case"
class SessionsTest < ApplicationSystemTestCase class SessionsTest < ApplicationSystemTestCase
setup do
@user = users(:user)
end
test "can log in and log out existing user" do test "can log in and log out existing user" do
user = users(:user)
visit new_session_path visit new_session_path
fill_in "username", with: @user.username fill_in "username", with: user.username
fill_in "password", with: "tolkien-abercrombie-hobb-barker" fill_in "password", with: "tolkien-abercrombie-hobb-barker"
click_button I18n.t("log_in") click_button I18n.t("log_in")
assert_text I18n.t("sessions.logged_in") assert_text I18n.t("sessions.logged_in")
click_link I18n.t("log_out") click_link I18n.t("log_out")
assert_text I18n.t("sessions.logged_out") assert_text I18n.t("sessions.logged_out")
end end
test "cannot log in as an unconfirmed user" do
user = users(:unconfirmed_user)
visit new_session_path
fill_in "username", with: user.username
fill_in "password", with: "tolkien-abercrombie-hobb-barker"
click_button I18n.t("log_in")
assert_text I18n.t("sessions.account_not_confirmed")
end
end end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require "application_system_test_case" require "application_system_test_case"
class RegisterUsersTest < ApplicationSystemTestCase class RegisterUsersTest < ApplicationSystemTestCase