Email verification workflow
This commit is contained in:
parent
c3dba29b8f
commit
ab0007651b
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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?
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1 +1,5 @@
|
||||||
|
<%= t(".greeting") %>
|
||||||
|
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
|
|
||||||
|
<%= t(".sign_off") %>
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<% flash.each do |type, message| %>
|
||||||
|
<aside class="flash <%= type %>">
|
||||||
|
<%= message %>
|
||||||
|
</aside>
|
||||||
|
<% end %>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<%= htmlify_email(t(".content")) %>
|
||||||
|
|
||||||
|
<%= link_to account_verification_url(@token),
|
||||||
|
account_verification_url(@token) %>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<%= t(".content", url: account_verification_url(@token)) %>
|
||||||
|
|
||||||
|
<%= account_verification_url(@token) %>
|
|
@ -0,0 +1 @@
|
||||||
|
<%= htmlify_email(t(".content")) %>
|
|
@ -0,0 +1 @@
|
||||||
|
<%= t(".content") %>
|
|
@ -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 %>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<% content_for :title, "Sign up" %>
|
||||||
|
|
||||||
|
<h1><%= t(".sign_up") %></h1>
|
||||||
|
|
||||||
|
<%= render partial: "users/form", locals: { user: @user } %>
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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 %>
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue