diff --git a/app/controllers/account_verifications_controller.rb b/app/controllers/account_verifications_controller.rb new file mode 100644 index 0000000..17c4fb0 --- /dev/null +++ b/app/controllers/account_verifications_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..d8f90c3 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/helpers/mailer_helper.rb b/app/helpers/mailer_helper.rb new file mode 100644 index 0000000..5f01347 --- /dev/null +++ b/app/helpers/mailer_helper.rb @@ -0,0 +1,6 @@ +module MailerHelper + def htmlify_email(content) + content.gsub!("\n\n", "

\n\n

") + "

#{content.chomp}

".html_safe + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..7ed3d14 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 2e96f49..32ca2d1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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? diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 255a84d..a7c1096 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,16 +1,22 @@ - Forg + <%= "#{yield(:title)} | " if content_for? :title %><%= t("forg") %> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + + +
+

<%= link_to t("forg"), root_path %>

+
+ <%= render partial: "shared/flash_messages" %> <%= yield %> diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 3aac900..533e85a 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -8,6 +8,8 @@ +

<%= t(".greeting", name: @user.first_name) %>

<%= yield %> + <%= t(".sign_off_html") %> diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 37f0bdd..1c919c0 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1 +1,5 @@ +<%= t(".greeting") %> + <%= yield %> + +<%= t(".sign_off") %> diff --git a/app/views/shared/_flash_messages.html.erb b/app/views/shared/_flash_messages.html.erb new file mode 100644 index 0000000..87e5afa --- /dev/null +++ b/app/views/shared/_flash_messages.html.erb @@ -0,0 +1,5 @@ +<% flash.each do |type, message| %> + +<% end %> diff --git a/app/views/user_mailer/email_verification.html.erb b/app/views/user_mailer/email_verification.html.erb new file mode 100644 index 0000000..405cacc --- /dev/null +++ b/app/views/user_mailer/email_verification.html.erb @@ -0,0 +1,4 @@ +<%= htmlify_email(t(".content")) %> + +<%= link_to account_verification_url(@token), + account_verification_url(@token) %> diff --git a/app/views/user_mailer/email_verification.text.erb b/app/views/user_mailer/email_verification.text.erb new file mode 100644 index 0000000..8ebeb1e --- /dev/null +++ b/app/views/user_mailer/email_verification.text.erb @@ -0,0 +1,3 @@ +<%= t(".content", url: account_verification_url(@token)) %> + +<%= account_verification_url(@token) %> diff --git a/app/views/user_mailer/email_verified.html.erb b/app/views/user_mailer/email_verified.html.erb new file mode 100644 index 0000000..f54e008 --- /dev/null +++ b/app/views/user_mailer/email_verified.html.erb @@ -0,0 +1 @@ +<%= htmlify_email(t(".content")) %> diff --git a/app/views/user_mailer/email_verified.text.erb b/app/views/user_mailer/email_verified.text.erb new file mode 100644 index 0000000..b154407 --- /dev/null +++ b/app/views/user_mailer/email_verified.text.erb @@ -0,0 +1 @@ +<%= t(".content") %> diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb new file mode 100644 index 0000000..a538505 --- /dev/null +++ b/app/views/users/_form.html.erb @@ -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 %> diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb new file mode 100644 index 0000000..b6470de --- /dev/null +++ b/app/views/users/new.html.erb @@ -0,0 +1,5 @@ +<% content_for :title, "Sign up" %> + +

<%= t(".sign_up") %>

+ +<%= render partial: "users/form", locals: { user: @user } %> diff --git a/config/environments/development.rb b/config/environments/development.rb index b40633f..0c881bf 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 diff --git a/config/environments/test.rb b/config/environments/test.rb index 64be917..b44b582 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae..338e709 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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: "

See you soon,
The Forg team

" + 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. + diff --git a/config/routes.rb b/config/routes.rb index a125ef0..d8047d3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20240414122652_add_verified_to_user.rb b/db/migrate/20240414122652_add_verified_to_user.rb new file mode 100644 index 0000000..9847d79 --- /dev/null +++ b/db/migrate/20240414122652_add_verified_to_user.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index aca4968..038bb9c 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[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" diff --git a/test/controllers/account_verifications_controller_test.rb b/test/controllers/account_verifications_controller_test.rb new file mode 100644 index 0000000..5f56120 --- /dev/null +++ b/test/controllers/account_verifications_controller_test.rb @@ -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 diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb new file mode 100644 index 0000000..db18f2e --- /dev/null +++ b/test/controllers/users_controller_test.rb @@ -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 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 5287208..23b42fc 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -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 %> diff --git a/test/helpers/mailer_helper_test.rb b/test/helpers/mailer_helper_test.rb new file mode 100644 index 0000000..ef65731 --- /dev/null +++ b/test/helpers/mailer_helper_test.rb @@ -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 +

One line here.

+ +

Another one here. + The same paragraph.

+ +

And another.

+ TEST + assert_equal expected_output, htmlify_email(input) + 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..51c962e --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -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 diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 4e932c9..ecee6ba 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -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)