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
|
||||
password_salt.last(10) # Invalidates when password changed
|
||||
end
|
||||
generates_token_for :email_verification, expires_in: 1.week do
|
||||
verified.to_s
|
||||
end
|
||||
|
||||
validates :username,
|
||||
presence: true,
|
||||
|
@ -14,7 +17,7 @@ class User < ApplicationRecord
|
|||
uniqueness: true,
|
||||
length: { minimum: 5, maximum: 100 },
|
||||
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 }
|
||||
validates :first_name,
|
||||
|
@ -24,6 +27,9 @@ class User < ApplicationRecord
|
|||
allow_nil: false,
|
||||
length: { maximum: 50 }
|
||||
|
||||
scope :verified, -> { where(verified: true) }
|
||||
scope :unverified, -> { where(verified: false) }
|
||||
|
||||
def full_name
|
||||
return first_name if last_name.blank?
|
||||
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Forg</title>
|
||||
<title><%= "#{yield(:title)} | " if content_for? :title %><%= t("forg") %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
<meta name="turbo-refresh-method" content="morph">
|
||||
<meta name="turbo-refresh-scroll" content="preserve">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<h1><%= link_to t("forg"), root_path %></h1>
|
||||
</header>
|
||||
<%= render partial: "shared/flash_messages" %>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<p><%= t(".greeting", name: @user.first_name) %></p>
|
||||
<%= yield %>
|
||||
<%= t(".sign_off_html") %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1 +1,5 @@
|
|||
<%= t(".greeting") %>
|
||||
|
||||
<%= 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
|
||||
|
||||
# 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.
|
||||
# config.action_view.annotate_rendered_view_with_filenames = true
|
||||
|
|
|
@ -54,7 +54,7 @@ Rails.application.configure do
|
|||
config.active_support.disallowed_deprecation_warnings = []
|
||||
|
||||
# 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.
|
||||
# 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:
|
||||
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
|
||||
# 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
|
||||
|
||||
# Defines the root path route ("/")
|
||||
# root "posts#index"
|
||||
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.
|
||||
|
||||
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
|
||||
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.datetime "created_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 ["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(first_name::text) >= 1", name: "chk_first_name_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:
|
||||
username: tsv
|
||||
DEFAULTS: &DEFAULTS
|
||||
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
|
||||
last_name: Vallender
|
||||
|
||||
unverified:
|
||||
<<: *DEFAULTS
|
||||
first_name: Unverified
|
||||
last_name: User
|
||||
verified: false
|
||||
|
||||
<% 1.upto(10) do |i| %>
|
||||
user_<%= i %>:
|
||||
username: <%= "user_#{i}" %>
|
||||
password_digest: <%= BCrypt::Password.create('password', cost: 5) %>
|
||||
email: <%= "user_#{i}@example.com" %>
|
||||
<<: *DEFAULTS
|
||||
first_name: <%= "User#{i}" %>
|
||||
last_name: <%= "User#{i}" %>
|
||||
<% 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")
|
||||
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
|
||||
user = users(:trevor)
|
||||
token = user.generate_token_for(:password_reset)
|
||||
|
|
Loading…
Reference in New Issue