mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
Initial email verification request implementation (#205)
This commit is contained in:
@@ -64,17 +64,17 @@ class SessionsController < ApplicationController
|
||||
|
||||
if params[:error].present?
|
||||
Rails.logger.error "GitHub OAuth error: #{params[:error]}"
|
||||
redirect_to root_path, alert: "Failed to authenticate with GitHub"
|
||||
redirect_to my_settings_path, alert: "Failed to authenticate with GitHub"
|
||||
return
|
||||
end
|
||||
|
||||
@user = User.from_github_token(params[:code], redirect_uri, current_user)
|
||||
|
||||
if @user&.persisted?
|
||||
redirect_to root_path, notice: "Successfully linked GitHub account!"
|
||||
redirect_to my_settings_path, notice: "Successfully linked GitHub account!"
|
||||
else
|
||||
Rails.logger.error "Failed to link GitHub account"
|
||||
redirect_to root_path, alert: "Failed to link GitHub account"
|
||||
redirect_to my_settings_path, alert: "Failed to link GitHub account"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -90,7 +90,49 @@ class SessionsController < ApplicationController
|
||||
redirect_to root_path(sign_in_email: true), notice: "Check your email for a sign-in link!"
|
||||
end
|
||||
|
||||
def add_email
|
||||
unless current_user
|
||||
redirect_to root_path, alert: "Please sign in first to add an email"
|
||||
return
|
||||
end
|
||||
|
||||
email = params[:email].downcase
|
||||
|
||||
if EmailAddress.exists?(email: email)
|
||||
redirect_to my_settings_path, alert: "This email is already associated with an account"
|
||||
return
|
||||
end
|
||||
|
||||
if EmailVerificationRequest.exists?(email: email)
|
||||
redirect_to my_settings_path, alert: "This email is already pending verification"
|
||||
return
|
||||
end
|
||||
|
||||
verification_request = current_user.email_verification_requests.create!(
|
||||
email: email
|
||||
)
|
||||
|
||||
if Rails.env.production?
|
||||
EmailVerificationMailer.verify_email(verification_request).deliver_later
|
||||
else
|
||||
EmailVerificationMailer.verify_email(verification_request).deliver_now
|
||||
end
|
||||
|
||||
redirect_to my_settings_path, notice: "Check your email to verify the new address!"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
redirect_to my_settings_path, alert: "Failed to add email: #{e.record.errors.full_messages.join(', ')}"
|
||||
end
|
||||
|
||||
def token
|
||||
verification_request = EmailVerificationRequest.valid.find_by(token: params[:token])
|
||||
|
||||
if verification_request
|
||||
verification_request.verify!
|
||||
redirect_to my_settings_path, notice: "Successfully verified your email address!"
|
||||
return
|
||||
end
|
||||
|
||||
# If no verification request found, try the old sign-in token system
|
||||
valid_token = SignInToken.where(token: params[:token], used_at: nil)
|
||||
.where("expires_at > ?", Time.current)
|
||||
.first
|
||||
@@ -100,7 +142,7 @@ class SessionsController < ApplicationController
|
||||
session[:user_id] = valid_token.user_id
|
||||
redirect_to root_path, notice: "Successfully signed in!"
|
||||
else
|
||||
redirect_to root_path, alert: "Invalid or expired sign-in link"
|
||||
redirect_to root_path, alert: "Invalid or expired link"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class CleanupExpiredEmailVerificationRequestsJob < ApplicationJob
|
||||
queue_as :interval_10s
|
||||
|
||||
def perform
|
||||
# Soft delete all expired and non-deleted verification requests in a single query
|
||||
EmailVerificationRequest.expired.where(deleted_at: nil)
|
||||
.update_all(deleted_at: Time.current)
|
||||
end
|
||||
end
|
||||
17
app/mailers/email_verification_mailer.rb
Normal file
17
app/mailers/email_verification_mailer.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class EmailVerificationMailer < ApplicationMailer
|
||||
# Subject can be set in your I18n file at config/locales/en.yml
|
||||
# with the following lookup:
|
||||
#
|
||||
# en.email_verification_mailer.verify_email.subject
|
||||
#
|
||||
def verify_email(verification_request)
|
||||
@verification_request = verification_request
|
||||
@user = verification_request.user
|
||||
@verification_url = auth_token_url(verification_request.token)
|
||||
|
||||
mail(
|
||||
to: verification_request.email,
|
||||
subject: "Verify your email address for Hackatime"
|
||||
)
|
||||
end
|
||||
end
|
||||
49
app/models/email_verification_request.rb
Normal file
49
app/models/email_verification_request.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
class EmailVerificationRequest < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
validates :email, presence: true,
|
||||
uniqueness: { conditions: -> { where(deleted_at: nil) } },
|
||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :token, presence: true, uniqueness: { conditions: -> { where(deleted_at: nil) } }
|
||||
validates :expires_at, presence: true
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiration, on: :create
|
||||
before_validation :downcase_email
|
||||
|
||||
scope :valid, -> { where("expires_at > ? AND deleted_at IS NULL", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def soft_delete!
|
||||
update!(deleted_at: Time.current)
|
||||
end
|
||||
|
||||
def verify!
|
||||
email_address = user.email_addresses.create!(
|
||||
email: email,
|
||||
source: :signing_in
|
||||
)
|
||||
|
||||
soft_delete!
|
||||
|
||||
email_address
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.token ||= SecureRandom.urlsafe_base64(32)
|
||||
end
|
||||
|
||||
def set_expiration
|
||||
self.expires_at ||= 30.minutes.from_now
|
||||
end
|
||||
|
||||
def downcase_email
|
||||
self.email = email.downcase if email.present?
|
||||
end
|
||||
end
|
||||
@@ -7,8 +7,9 @@ class User < ApplicationRecord
|
||||
validates :timezone, inclusion: { in: TZInfo::Timezone.all.map(&:identifier) }, allow_nil: false
|
||||
|
||||
has_many :heartbeats
|
||||
has_many :email_addresses
|
||||
has_many :sign_in_tokens
|
||||
has_many :email_addresses, dependent: :destroy
|
||||
has_many :email_verification_requests, dependent: :destroy
|
||||
has_many :sign_in_tokens, dependent: :destroy
|
||||
has_many :project_repo_mappings
|
||||
|
||||
has_many :hackatime_heartbeats,
|
||||
|
||||
17
app/views/email_verification_mailer/verify_email.html.erb
Normal file
17
app/views/email_verification_mailer/verify_email.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<h1>Verify your email address for Hackatime</h1>
|
||||
<p>
|
||||
Hi <%= @user.display_name %>,
|
||||
</p>
|
||||
<p>
|
||||
You've requested to add <%= @verification_request.email %> to your Hackatime account.
|
||||
Click the link below to verify this email address:
|
||||
</p>
|
||||
<p>
|
||||
<%= link_to 'Verify email address', @verification_url %>
|
||||
</p>
|
||||
<p>
|
||||
This link will expire in 30 minutes and can only be used once.
|
||||
</p>
|
||||
<p>
|
||||
If you didn't request this email, you can safely ignore it.
|
||||
</p>
|
||||
12
app/views/email_verification_mailer/verify_email.text.erb
Normal file
12
app/views/email_verification_mailer/verify_email.text.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
Verify your email address for Hackatime
|
||||
|
||||
Hi <%= @user.display_name %>,
|
||||
|
||||
You've requested to add <%= @verification_request.email %> to your Hackatime account.
|
||||
Click the link below to verify this email address:
|
||||
|
||||
<%= @verification_url %>
|
||||
|
||||
This link will expire in 30 minutes and can only be used once.
|
||||
|
||||
If you didn't request this email, you can safely ignore it.
|
||||
@@ -52,6 +52,11 @@
|
||||
Letter Opener
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% dev_tool(nil, "li") do %>
|
||||
<%= link_to '/rails/mailers', class: "nav-item #{current_page?('/rails/mailers') ? 'active' : ''}" do %>
|
||||
Mailers
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% admin_tool(nil, "li") do %>
|
||||
<%= link_to avo_path, class: "nav-item #{current_page?(avo_path) ? 'active' : ''}" do %>
|
||||
Avo
|
||||
|
||||
@@ -104,6 +104,15 @@
|
||||
<% else %>
|
||||
<p>No email addresses found.</p>
|
||||
<% end %>
|
||||
|
||||
<div class="add-email-form">
|
||||
<%= form_tag add_email_auth_path, data: { turbo: false } do %>
|
||||
<div class="field">
|
||||
<%= email_field_tag :email, nil, placeholder: "Add another email address", required: true %>
|
||||
</div>
|
||||
<%= submit_tag "Add Email" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -45,6 +45,10 @@ Rails.application.configure do
|
||||
cron: "0 10 * * *",
|
||||
class: "ScanGithubReposJob"
|
||||
},
|
||||
cleanup_expired_email_verification_requests: {
|
||||
cron: "* * * * *",
|
||||
class: "CleanupExpiredEmailVerificationRequestsJob"
|
||||
},
|
||||
cache_active_user_graph_data_job: {
|
||||
cron: "*/10 * * * *",
|
||||
class: "Cache::ActiveUsersGraphDataJob",
|
||||
|
||||
@@ -49,6 +49,7 @@ Rails.application.routes.draw do
|
||||
get "/auth/github", to: "sessions#github_new", as: :github_auth
|
||||
get "/auth/github/callback", to: "sessions#github_create"
|
||||
post "/auth/email", to: "sessions#email", as: :email_auth
|
||||
post "/auth/email/add", to: "sessions#add_email", as: :add_email_auth
|
||||
get "/auth/token/:token", to: "sessions#token", as: :auth_token
|
||||
get "/auth/close_window", to: "sessions#close_window", as: :close_window
|
||||
delete "signout", to: "sessions#destroy", as: "signout"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateEmailVerificationRequests < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :email_verification_requests do |t|
|
||||
t.string :email
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :token
|
||||
t.datetime :expires_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :email_verification_requests, :email, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddDeletedAtToEmailVerificationRequests < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :email_verification_requests, :deleted_at, :datetime
|
||||
end
|
||||
end
|
||||
15
db/schema.rb
generated
15
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_05_05_045020) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_05_05_152654) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
@@ -79,6 +79,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_05_045020) do
|
||||
t.index ["user_id"], name: "index_email_addresses_on_user_id"
|
||||
end
|
||||
|
||||
create_table "email_verification_requests", force: :cascade do |t|
|
||||
t.string "email"
|
||||
t.bigint "user_id", null: false
|
||||
t.string "token"
|
||||
t.datetime "expires_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "deleted_at"
|
||||
t.index ["email"], name: "index_email_verification_requests_on_email", unique: true
|
||||
t.index ["user_id"], name: "index_email_verification_requests_on_user_id"
|
||||
end
|
||||
|
||||
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -312,6 +324,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_05_045020) do
|
||||
|
||||
add_foreign_key "api_keys", "users"
|
||||
add_foreign_key "email_addresses", "users"
|
||||
add_foreign_key "email_verification_requests", "users"
|
||||
add_foreign_key "heartbeats", "users"
|
||||
add_foreign_key "leaderboard_entries", "leaderboards"
|
||||
add_foreign_key "leaderboard_entries", "users"
|
||||
|
||||
Reference in New Issue
Block a user