Email verification workflow

This commit is contained in:
Trevor Vallender 2024-04-14 20:01:32 +01:00
parent c3dba29b8f
commit ab0007651b
27 changed files with 286 additions and 47 deletions

View File

@ -0,0 +1,13 @@
class AccountVerificationsController < ApplicationController
def show
user = User.find_by_token_for(:email_verification, params[:id])
unless user
flash[:alert] = t(".error")
redirect_to :root and return
end
user.update(verified: true)
UserMailer.with(user: user).email_verified.deliver_later
flash[:notice] = t(".success")
redirect_to :root # TODO: New session path
end
end

View File

@ -0,0 +1,31 @@
class UsersController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
token = @user.generate_token_for(:email_verification)
UserMailer.with(user: @user, token: token).email_verification.deliver_later
flash[:notice] = t(".success", name: @user.first_name)
redirect_to :root
else
flash[:alert] = t(".error", error: @user.errors.full_messages.to_sentence)
render :new, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(
:username,
:password,
:password_confirmation,
:email,
:first_name,
:last_name,
)
end
end

View File

@ -0,0 +1,6 @@
module MailerHelper
def htmlify_email(content)
content.gsub!("\n\n", "</p>\n\n<p>")
"<p>#{content.chomp}</p>".html_safe
end
end

View File

@ -0,0 +1,16 @@
class UserMailer < ApplicationMailer
helper(:mailer)
def email_verification
@user = params[:user]
@token = params[:token]
mail(to: @user.email, subject: "[Forg] Verify your email")
end
def email_verified
@user = params[:user]
mail(to: @user.email, subject: "[Forg] Your email has been verified")
end
end

View File

@ -3,6 +3,9 @@ class User < ApplicationRecord
generates_token_for :password_reset, expires_in: 4.hours do generates_token_for :password_reset, expires_in: 4.hours do
password_salt.last(10) # Invalidates when password changed password_salt.last(10) # Invalidates when password changed
end end
generates_token_for :email_verification, expires_in: 1.week do
verified.to_s
end
validates :username, validates :username,
presence: true, presence: true,
@ -14,7 +17,7 @@ class User < ApplicationRecord
uniqueness: true, uniqueness: true,
length: { minimum: 5, maximum: 100 }, length: { minimum: 5, maximum: 100 },
format: { with: URI::MailTo::EMAIL_REGEXP, format: { with: URI::MailTo::EMAIL_REGEXP,
message: "must be a valid email address", message: I18n.t("users.validations.email_format"),
} }
normalizes :email, with: ->(email) { email.strip.downcase } normalizes :email, with: ->(email) { email.strip.downcase }
validates :first_name, validates :first_name,
@ -24,6 +27,9 @@ class User < ApplicationRecord
allow_nil: false, allow_nil: false,
length: { maximum: 50 } length: { maximum: 50 }
scope :verified, -> { where(verified: true) }
scope :unverified, -> { where(verified: false) }
def full_name def full_name
return first_name if last_name.blank? return first_name if last_name.blank?

View File

@ -1,16 +1,22 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Forg</title> <title><%= "#{yield(:title)} | " if content_for? :title %><%= t("forg") %></title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %> <%= javascript_importmap_tags %>
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
</head> </head>
<body> <body>
<header>
<h1><%= link_to t("forg"), root_path %></h1>
</header>
<%= render partial: "shared/flash_messages" %>
<%= yield %> <%= yield %>
</body> </body>
</html> </html>

View File

@ -8,6 +8,8 @@
</head> </head>
<body> <body>
<p><%= t(".greeting", name: @user.first_name) %></p>
<%= yield %> <%= yield %>
<%= t(".sign_off_html") %>
</body> </body>
</html> </html>

View File

@ -1 +1,5 @@
<%= t(".greeting") %>
<%= yield %> <%= yield %>
<%= t(".sign_off") %>

View File

@ -0,0 +1,5 @@
<% flash.each do |type, message| %>
<aside class="flash <%= type %>">
<%= message %>
</aside>
<% end %>

View File

@ -0,0 +1,4 @@
<%= htmlify_email(t(".content")) %>
<%= link_to account_verification_url(@token),
account_verification_url(@token) %>

View File

@ -0,0 +1,3 @@
<%= t(".content", url: account_verification_url(@token)) %>
<%= account_verification_url(@token) %>

View File

@ -0,0 +1 @@
<%= htmlify_email(t(".content")) %>

View File

@ -0,0 +1 @@
<%= t(".content") %>

View File

@ -0,0 +1,23 @@
<%# locals: (user: User.new) -%>
<%= form_with model: user do |f| %>
<%= f.label :username %>
<%= f.text_field :username %>
<%= f.label :first_name %>
<%= f.text_field :first_name %>
<%= f.label :last_name %>
<%= f.text_field :last_name %>
<%= f.label :email %>
<%= f.text_field :email %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation %>
<%= f.submit %>
<% end %>

View File

@ -0,0 +1,5 @@
<% content_for :title, "Sign up" %>
<h1><%= t(".sign_up") %></h1>
<%= render partial: "users/form", locals: { user: @user } %>

View File

@ -63,7 +63,7 @@ Rails.application.configure do
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

View File

@ -54,7 +54,7 @@ Rails.application.configure do
config.active_support.disallowed_deprecation_warnings = [] config.active_support.disallowed_deprecation_warnings = []
# 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

View File

@ -1,31 +1,33 @@
# Files in the config/locales directory are used for internationalization and
# are automatically loaded by Rails. If you want to use locales other than
# English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
# I18n.t "hello"
#
# In views, this is aliased to just `t`:
#
# <%= t("hello") %>
#
# To use a different locale, set it with `I18n.locale`:
#
# I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# To learn more about the API, please read the Rails Internationalization guide
# at https://guides.rubyonrails.org/i18n.html.
#
# Be aware that YAML interprets the following case-insensitive strings as
# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
# must be quoted to be interpreted as strings. For example:
#
# en:
# "yes": yup
# enabled: "ON"
en: en:
hello: "Hello world" forg: Forg
layouts:
mailer:
greeting: "Hello, %{name}"
sign_off: |
See you soon,
The Forg team
sign_off_html: "<p>See you soon,<br>The Forg team</p>"
account_verifications:
show:
success: "Thanks for verifying your email address! You can now log in."
error: "Invalid token, could not verify your account."
users:
validations:
email_format: "must be a valid email address"
new:
sign_up: Sign up
create:
error: "Could not create account: %{error}"
success: "Thanks for joining Forg, %{name}! Please check your email to verify your address."
user_mailer:
email_verified:
content: |-
Thanks for verifying your email address, and welcome to Forg!
You can now go ahead and log in. Enjoy!
email_verification:
content: |-
If you did not sign up for Forg, please ignore this email.
Otherwise, please visit the link below to verify your email address.

View File

@ -1,10 +1,9 @@
Rails.application.routes.draw do Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html default_url_options host: "forg-app.com"
root "users#new"
resources :users, only: %i[new create]
resources :account_verifications, only: %i[show]
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check get "up" => "rails/health#show", as: :rails_health_check
# Defines the root path route ("/")
# root "posts#index"
end end

View File

@ -0,0 +1,6 @@
class AddVerifiedToUser < ActiveRecord::Migration[7.1]
def change
add_column :users, :verified, :boolean, null: false, default: false
add_index :users, :verified
end
end

4
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.1].define(version: 2024_04_13_152553) do ActiveRecord::Schema[7.1].define(version: 2024_04_14_122652) 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,8 +22,10 @@ ActiveRecord::Schema[7.1].define(version: 2024_04_13_152553) do
t.string "last_name", limit: 50, default: "", null: false t.string "last_name", limit: 50, default: "", 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.boolean "verified", default: false, null: false
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
t.index ["verified"], name: "index_users_on_verified"
t.check_constraint "length(email::text) >= 5", name: "chk_email_min_length" t.check_constraint "length(email::text) >= 5", name: "chk_email_min_length"
t.check_constraint "length(first_name::text) >= 1", name: "chk_first_name_min_length" t.check_constraint "length(first_name::text) >= 1", name: "chk_first_name_min_length"
t.check_constraint "length(username::text) >= 3", name: "chk_username_min_length" t.check_constraint "length(username::text) >= 3", name: "chk_username_min_length"

View File

@ -0,0 +1,14 @@
require "test_helper"
class AccountVerificationsControllerTest < ActionDispatch::IntegrationTest
test "should verify email" do
user = users(:unverified)
token = user.generate_token_for(:email_verification)
assert_emails(+1) do
get account_verification_url(token)
end
user.reload
assert user.verified
assert_redirected_to root_url
end
end

View File

@ -0,0 +1,40 @@
require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
test "should get new" do
get new_user_url
assert_response :success
end
test "should create user" do
assert_changes("User.count", +1) do
post(users_url, params: { user: user_params })
end
assert_redirected_to :root
end
test "should email user for confirmation" do
assert_emails(+1) do
post(users_url, params: { user: user_params })
end
end
test "should alert to invalid user" do
assert_no_changes("User.count") do
post(users_url, params: { user: { username: "new_user" } })
end
assert_response :unprocessable_entity
end
private
def user_params
{
username: "new_user",
password: "password",
email: "new_user@ex.com",
first_name: "new",
last_name: "user",
}
end
end

View File

@ -1,15 +1,24 @@
trevor: DEFAULTS: &DEFAULTS
username: tsv
password_digest: <%= BCrypt::Password.create('password', cost: 5) %> password_digest: <%= BCrypt::Password.create('password', cost: 5) %>
email: trevor@example.com username: $LABEL
verified: true
email: $LABEL<%= "@example.com" %>
trevor:
<<: *DEFAULTS
username: tsv
first_name: Trevor first_name: Trevor
last_name: Vallender last_name: Vallender
unverified:
<<: *DEFAULTS
first_name: Unverified
last_name: User
verified: false
<% 1.upto(10) do |i| %> <% 1.upto(10) do |i| %>
user_<%= i %>: user_<%= i %>:
username: <%= "user_#{i}" %> <<: *DEFAULTS
password_digest: <%= BCrypt::Password.create('password', cost: 5) %>
email: <%= "user_#{i}@example.com" %>
first_name: <%= "User#{i}" %> first_name: <%= "User#{i}" %>
last_name: <%= "User#{i}" %> last_name: <%= "User#{i}" %>
<% end %> <% end %>

View File

@ -0,0 +1,23 @@
require "test_helper"
class MailerHelperTest < ActionView::TestCase
test "htmlify_email adds correct tags" do
input = <<~TEST.chomp
One line here.
Another one here.
The same paragraph.
And another.
TEST
expected_output = <<~TEST.chomp
<p>One line here.</p>
<p>Another one here.
The same paragraph.</p>
<p>And another.</p>
TEST
assert_equal expected_output, htmlify_email(input)
end
end

View File

@ -0,0 +1,10 @@
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
def email_verification
UserMailer.with(user: User.first, token: "token").email_verification
end
def email_verified
UserMailer.with(user: User.first).email_verified
end
end

View File

@ -38,6 +38,14 @@ class UserTest < ActiveSupport::TestCase
assert_must_exist(users(:trevor), "first_name") assert_must_exist(users(:trevor), "first_name")
end end
test "email verification token is invalid after email verified" do
user = users(:unverified)
token = user.generate_token_for(:email_verification)
assert_equal user, User.find_by_token_for(:email_verification, token)
user.update(verified: true)
assert_nil User.find_by_token_for(:email_verification, token)
end
test "password reset token is invalid after password changed" do test "password reset token is invalid after password changed" do
user = users(:trevor) user = users(:trevor)
token = user.generate_token_for(:password_reset) token = user.generate_token_for(:password_reset)