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 @@ +
+ 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 %> + +