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?
|
if params[:error].present?
|
||||||
Rails.logger.error "GitHub OAuth error: #{params[:error]}"
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@user = User.from_github_token(params[:code], redirect_uri, current_user)
|
@user = User.from_github_token(params[:code], redirect_uri, current_user)
|
||||||
|
|
||||||
if @user&.persisted?
|
if @user&.persisted?
|
||||||
redirect_to root_path, notice: "Successfully linked GitHub account!"
|
redirect_to my_settings_path, notice: "Successfully linked GitHub account!"
|
||||||
else
|
else
|
||||||
Rails.logger.error "Failed to link GitHub account"
|
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
|
||||||
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!"
|
redirect_to root_path(sign_in_email: true), notice: "Check your email for a sign-in link!"
|
||||||
end
|
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
|
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)
|
valid_token = SignInToken.where(token: params[:token], used_at: nil)
|
||||||
.where("expires_at > ?", Time.current)
|
.where("expires_at > ?", Time.current)
|
||||||
.first
|
.first
|
||||||
@@ -100,7 +142,7 @@ class SessionsController < ApplicationController
|
|||||||
session[:user_id] = valid_token.user_id
|
session[:user_id] = valid_token.user_id
|
||||||
redirect_to root_path, notice: "Successfully signed in!"
|
redirect_to root_path, notice: "Successfully signed in!"
|
||||||
else
|
else
|
||||||
redirect_to root_path, alert: "Invalid or expired sign-in link"
|
redirect_to root_path, alert: "Invalid or expired link"
|
||||||
end
|
end
|
||||||
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
|
validates :timezone, inclusion: { in: TZInfo::Timezone.all.map(&:identifier) }, allow_nil: false
|
||||||
|
|
||||||
has_many :heartbeats
|
has_many :heartbeats
|
||||||
has_many :email_addresses
|
has_many :email_addresses, dependent: :destroy
|
||||||
has_many :sign_in_tokens
|
has_many :email_verification_requests, dependent: :destroy
|
||||||
|
has_many :sign_in_tokens, dependent: :destroy
|
||||||
has_many :project_repo_mappings
|
has_many :project_repo_mappings
|
||||||
|
|
||||||
has_many :hackatime_heartbeats,
|
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
|
Letter Opener
|
||||||
<% end %>
|
<% end %>
|
||||||
<% 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 %>
|
<% admin_tool(nil, "li") do %>
|
||||||
<%= link_to avo_path, class: "nav-item #{current_page?(avo_path) ? 'active' : ''}" do %>
|
<%= link_to avo_path, class: "nav-item #{current_page?(avo_path) ? 'active' : ''}" do %>
|
||||||
Avo
|
Avo
|
||||||
|
|||||||
@@ -104,6 +104,15 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<p>No email addresses found.</p>
|
<p>No email addresses found.</p>
|
||||||
<% end %>
|
<% 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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ Rails.application.configure do
|
|||||||
cron: "0 10 * * *",
|
cron: "0 10 * * *",
|
||||||
class: "ScanGithubReposJob"
|
class: "ScanGithubReposJob"
|
||||||
},
|
},
|
||||||
|
cleanup_expired_email_verification_requests: {
|
||||||
|
cron: "* * * * *",
|
||||||
|
class: "CleanupExpiredEmailVerificationRequestsJob"
|
||||||
|
},
|
||||||
cache_active_user_graph_data_job: {
|
cache_active_user_graph_data_job: {
|
||||||
cron: "*/10 * * * *",
|
cron: "*/10 * * * *",
|
||||||
class: "Cache::ActiveUsersGraphDataJob",
|
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", to: "sessions#github_new", as: :github_auth
|
||||||
get "/auth/github/callback", to: "sessions#github_create"
|
get "/auth/github/callback", to: "sessions#github_create"
|
||||||
post "/auth/email", to: "sessions#email", as: :email_auth
|
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/token/:token", to: "sessions#token", as: :auth_token
|
||||||
get "/auth/close_window", to: "sessions#close_window", as: :close_window
|
get "/auth/close_window", to: "sessions#close_window", as: :close_window
|
||||||
delete "signout", to: "sessions#destroy", as: "signout"
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
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"
|
t.index ["user_id"], name: "index_email_addresses_on_user_id"
|
||||||
end
|
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|
|
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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 "api_keys", "users"
|
||||||
add_foreign_key "email_addresses", "users"
|
add_foreign_key "email_addresses", "users"
|
||||||
|
add_foreign_key "email_verification_requests", "users"
|
||||||
add_foreign_key "heartbeats", "users"
|
add_foreign_key "heartbeats", "users"
|
||||||
add_foreign_key "leaderboard_entries", "leaderboards"
|
add_foreign_key "leaderboard_entries", "leaderboards"
|
||||||
add_foreign_key "leaderboard_entries", "users"
|
add_foreign_key "leaderboard_entries", "users"
|
||||||
|
|||||||
Reference in New Issue
Block a user