diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0e376e4..b167eff 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/jobs/cleanup_expired_email_verification_requests_job.rb b/app/jobs/cleanup_expired_email_verification_requests_job.rb new file mode 100644 index 0000000..2afdd14 --- /dev/null +++ b/app/jobs/cleanup_expired_email_verification_requests_job.rb @@ -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 diff --git a/app/mailers/email_verification_mailer.rb b/app/mailers/email_verification_mailer.rb new file mode 100644 index 0000000..b4536e1 --- /dev/null +++ b/app/mailers/email_verification_mailer.rb @@ -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 diff --git a/app/models/email_verification_request.rb b/app/models/email_verification_request.rb new file mode 100644 index 0000000..e34d1fc --- /dev/null +++ b/app/models/email_verification_request.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 446ffe2..0a04624 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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, diff --git a/app/views/email_verification_mailer/verify_email.html.erb b/app/views/email_verification_mailer/verify_email.html.erb new file mode 100644 index 0000000..4ff8926 --- /dev/null +++ b/app/views/email_verification_mailer/verify_email.html.erb @@ -0,0 +1,17 @@ +

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: +

+

+ <%= link_to 'Verify 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. +

diff --git a/app/views/email_verification_mailer/verify_email.text.erb b/app/views/email_verification_mailer/verify_email.text.erb new file mode 100644 index 0000000..01fdaa1 --- /dev/null +++ b/app/views/email_verification_mailer/verify_email.text.erb @@ -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. diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index 3aa09a3..626319a 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -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 diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 36827d0..765e5ee 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -104,6 +104,15 @@ <% else %>

No email addresses found.

<% end %> + +
+ <%= form_tag add_email_auth_path, data: { turbo: false } do %> +
+ <%= email_field_tag :email, nil, placeholder: "Add another email address", required: true %> +
+ <%= submit_tag "Add Email" %> + <% end %> +
diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 71bf977..b1d2b47 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -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", diff --git a/config/routes.rb b/config/routes.rb index 55f24a7..c6cc073 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/db/migrate/20250505151057_create_email_verification_requests.rb b/db/migrate/20250505151057_create_email_verification_requests.rb new file mode 100644 index 0000000..7663490 --- /dev/null +++ b/db/migrate/20250505151057_create_email_verification_requests.rb @@ -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 diff --git a/db/migrate/20250505152654_add_deleted_at_to_email_verification_requests.rb b/db/migrate/20250505152654_add_deleted_at_to_email_verification_requests.rb new file mode 100644 index 0000000..0d7b390 --- /dev/null +++ b/db/migrate/20250505152654_add_deleted_at_to_email_verification_requests.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToEmailVerificationRequests < ActiveRecord::Migration[8.0] + def change + add_column :email_verification_requests, :deleted_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 0646886..bfc57c9 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[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"