mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
Add github auth (#68)
* Add github auth * Add project mapping background jobs * Explicitly allow redirect to github for signin
This commit is contained in:
@@ -140,6 +140,25 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.auth-button.github {
|
||||
background-color: #24292e;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.auth-button.github:hover {
|
||||
background-color: #2f363d;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
margin: 1rem 0;
|
||||
color: #666;
|
||||
@@ -266,3 +285,20 @@
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background-color: #24292e;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #2f363d;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
||||
ip_address: request.remote_ip
|
||||
})
|
||||
new_heartbeat = Heartbeat.find_or_create_by(attrs)
|
||||
queue_project_mapping(heartbeat[:project])
|
||||
results << [ new_heartbeat.attributes, 201 ]
|
||||
rescue => e
|
||||
Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}")
|
||||
@@ -75,6 +76,10 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
||||
results
|
||||
end
|
||||
|
||||
def queue_project_mapping(project_name)
|
||||
AttemptProjectRepoMappingJob.perform_later(@user.id, heartbeat[:project])
|
||||
end
|
||||
|
||||
def set_user
|
||||
api_header = request.headers["Authorization"]
|
||||
raw_token = api_header&.split(" ")&.last
|
||||
|
||||
@@ -33,6 +33,39 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def github_new
|
||||
redirect_uri = url_for(action: :github_create, only_path: false)
|
||||
Rails.logger.info "Starting GitHub OAuth flow with redirect URI: #{redirect_uri}"
|
||||
redirect_to User.github_authorize_url(redirect_uri),
|
||||
allow_other_host: "https://github.com"
|
||||
end
|
||||
|
||||
def github_create
|
||||
redirect_uri = url_for(action: :github_create, only_path: false)
|
||||
|
||||
if params[:error].present?
|
||||
Rails.logger.error "GitHub OAuth error: #{params[:error]}"
|
||||
redirect_to root_path, alert: "Failed to authenticate with GitHub"
|
||||
return
|
||||
end
|
||||
|
||||
@user = User.from_github_token(params[:code], redirect_uri, current_user)
|
||||
|
||||
if @user&.persisted?
|
||||
session[:user_id] = @user.id unless current_user # Only set session if this is a new sign-in
|
||||
|
||||
if @user.data_migration_jobs.empty?
|
||||
# if they don't have a data migration job, add one to the queue
|
||||
OneTime::MigrateUserFromHackatimeJob.perform_later(@user.id)
|
||||
end
|
||||
|
||||
redirect_to root_path, notice: current_user ? "Successfully linked GitHub account!" : "Successfully signed in with GitHub!"
|
||||
else
|
||||
Rails.logger.error "Failed to create/update user from GitHub data"
|
||||
redirect_to root_path, alert: "Failed to sign in with GitHub"
|
||||
end
|
||||
end
|
||||
|
||||
def email
|
||||
email = params[:email].downcase
|
||||
|
||||
|
||||
64
app/jobs/attempt_project_repo_mapping_job.rb
Normal file
64
app/jobs/attempt_project_repo_mapping_job.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
class AttemptProjectRepoMappingJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
include GoodJob::ActiveJobExtensions::Concurrency
|
||||
|
||||
good_job_control_concurrency_with(
|
||||
total_limit: 1,
|
||||
key: -> { "attempt_project_repo_mapping_job_#{arguments.first}_#{arguments.last}" },
|
||||
drop: true
|
||||
)
|
||||
|
||||
def perform(user_id, project_name)
|
||||
@user = User.find(user_id)
|
||||
|
||||
return unless @user.github_uid.present?
|
||||
return unless @user.github_username.present?
|
||||
return if @user.project_repo_mappings.exists?(project_name: project_name)
|
||||
|
||||
# Search for the project on GitHub
|
||||
repo_url = search_for_repo(@user.github_username, project_name)
|
||||
if repo_url.present?
|
||||
puts "creating mapping"
|
||||
create_mapping(project_name, repo_url)
|
||||
return
|
||||
end
|
||||
|
||||
# now search for orgs the user is a member of & check in those places for the repo
|
||||
list_orgs.each do |org|
|
||||
repo_url = search_for_repo(org["login"], project_name)
|
||||
if repo_url.present?
|
||||
create_mapping(project_name, repo_url)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_mapping(project_name, repo_url)
|
||||
@user.project_repo_mappings.create!(project_name: project_name, repo_url: repo_url)
|
||||
end
|
||||
|
||||
def search_for_repo(org_name, project_name)
|
||||
puts "Searching for repo #{project_name} in user #{org_name}"
|
||||
response = HTTP.auth("Bearer #{@user.github_access_token}")
|
||||
.get("https://api.github.com/repos/#{org_name}/#{project_name}")
|
||||
|
||||
Rails.logger.info "GitHub org repos response: #{response.body}"
|
||||
# if not found, return nil
|
||||
return nil unless response.status.success?
|
||||
|
||||
repo = JSON.parse(response.body)
|
||||
puts "repo: #{repo}"
|
||||
repo["html_url"]
|
||||
end
|
||||
|
||||
def list_orgs
|
||||
response = HTTP.auth("Bearer #{@user.github_access_token}")
|
||||
.get("https://api.github.com/users/#{@user.github_username}/orgs")
|
||||
|
||||
Rails.logger.info "GitHub orgs response: #{response.body}"
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
end
|
||||
33
app/jobs/scan_github_repos_job.rb
Normal file
33
app/jobs/scan_github_repos_job.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class ScanGithubReposJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
include GoodJob::ActiveJobExtensions::Concurrency
|
||||
|
||||
# Only allow one instance of this job to run at a time
|
||||
good_job_control_concurrency_with(
|
||||
total_limit: 1,
|
||||
key: -> { "scan_github_repos_job_#{arguments.first.presence || 'all'}" },
|
||||
drop: true
|
||||
)
|
||||
|
||||
def perform(user_id = nil)
|
||||
scope = User.where.not(github_uid: nil)
|
||||
scope = scope.where(id: user_id) if user_id.present?
|
||||
|
||||
puts "Scanning GitHub repos for #{scope.count} users"
|
||||
scope.find_each(batch_size: 100) do |user|
|
||||
Rails.logger.info "Scanning GitHub repos for user #{user.id} (#{user.github_username})"
|
||||
|
||||
# existing mappings
|
||||
existing_mappings = user.project_repo_mappings.pluck(:project_name)
|
||||
|
||||
# Get unique project names from user's heartbeats
|
||||
project_names = user.heartbeats.where.not(project: existing_mappings)
|
||||
.distinct.pluck(:project).compact
|
||||
|
||||
project_names.each do |project_name|
|
||||
AttemptProjectRepoMappingJob.perform_later(user.id, project_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,9 @@
|
||||
class User < ApplicationRecord
|
||||
has_paper_trail
|
||||
encrypts :slack_access_token
|
||||
encrypts :slack_access_token, :github_access_token
|
||||
|
||||
validates :slack_uid, uniqueness: true, allow_nil: true
|
||||
validates :github_uid, uniqueness: true, allow_nil: true
|
||||
validates :timezone, inclusion: { in: TZInfo::Timezone.all.map(&:identifier) }, allow_nil: false
|
||||
|
||||
has_many :heartbeats
|
||||
@@ -160,6 +161,17 @@ class User < ApplicationRecord
|
||||
URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}")
|
||||
end
|
||||
|
||||
def self.github_authorize_url(redirect_uri)
|
||||
params = {
|
||||
client_id: ENV["GITHUB_CLIENT_ID"],
|
||||
redirect_uri: redirect_uri,
|
||||
state: SecureRandom.hex(24),
|
||||
scope: "user:email"
|
||||
}
|
||||
|
||||
URI.parse("https://github.com/login/oauth/authorize?#{params.to_query}")
|
||||
end
|
||||
|
||||
def self.from_slack_token(code, redirect_uri)
|
||||
# Exchange code for token
|
||||
response = HTTP.post("https://slack.com/api/oauth.v2.access", form: {
|
||||
@@ -208,8 +220,81 @@ class User < ApplicationRecord
|
||||
nil
|
||||
end
|
||||
|
||||
def self.from_github_token(code, redirect_uri, current_user = nil)
|
||||
# Exchange code for token
|
||||
response = HTTP.headers(accept: "application/json")
|
||||
.post("https://github.com/login/oauth/access_token", form: {
|
||||
client_id: ENV["GITHUB_CLIENT_ID"],
|
||||
client_secret: ENV["GITHUB_CLIENT_SECRET"],
|
||||
code: code,
|
||||
redirect_uri: redirect_uri
|
||||
})
|
||||
|
||||
data = JSON.parse(response.body.to_s)
|
||||
Rails.logger.info "GitHub OAuth response: #{data.inspect}"
|
||||
return nil unless data["access_token"]
|
||||
|
||||
# Get user info
|
||||
user_response = HTTP.auth("Bearer #{data['access_token']}")
|
||||
.get("https://api.github.com/user")
|
||||
|
||||
user_data = JSON.parse(user_response.body.to_s)
|
||||
Rails.logger.info "GitHub user data: #{user_data.inspect}"
|
||||
Rails.logger.info "GitHub user ID type: #{user_data['id'].class}"
|
||||
|
||||
# Get user email from profile
|
||||
primary_email = user_data["email"]
|
||||
return nil unless primary_email
|
||||
|
||||
# If we have a current user, update that user
|
||||
if current_user
|
||||
user = current_user
|
||||
else
|
||||
# For new sign-ins, try to find user by GitHub ID or email
|
||||
user = User.find_by(github_uid: user_data["id"])
|
||||
unless user
|
||||
email_address = EmailAddress.find_by(email: primary_email)
|
||||
user = email_address&.user
|
||||
end
|
||||
# If still no user found, create a new one
|
||||
user ||= begin
|
||||
u = User.new
|
||||
u.email_addresses << EmailAddress.new(email: primary_email)
|
||||
u
|
||||
end
|
||||
end
|
||||
|
||||
# Update GitHub-specific fields
|
||||
user.github_uid = user_data["id"]
|
||||
user.username ||= user_data["login"]
|
||||
user.github_username = user_data["login"]
|
||||
user.github_avatar_url = user_data["avatar_url"]
|
||||
user.github_access_token = data["access_token"]
|
||||
|
||||
# Add the GitHub email if it's not already associated
|
||||
unless user.email_addresses.exists?(email: primary_email)
|
||||
begin
|
||||
user.email_addresses << EmailAddress.new(email: primary_email)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
# If the email already exists for another user, we can ignore it
|
||||
Rails.logger.info "Email #{primary_email} already exists for another user"
|
||||
end
|
||||
end
|
||||
|
||||
user.save!
|
||||
|
||||
ScanGithubReposJob.perform_later(user.id)
|
||||
|
||||
user
|
||||
rescue => e
|
||||
Rails.logger.error "Error creating user from GitHub data: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
nil
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
return self.slack_avatar_url if self.slack_avatar_url.present?
|
||||
return self.github_avatar_url if self.github_avatar_url.present?
|
||||
initials = self.email_addresses&.first&.email[0..1]&.upcase
|
||||
hashed_initials = Digest::SHA256.hexdigest(initials)[0..5]
|
||||
"https://i2.wp.com/ui-avatars.com/api/#{initials}/48/#{hashed_initials}/fff?ssl=1" if initials.present?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= turbo_frame_tag "activity_graph" do %>
|
||||
<%= cache ["activity_graph", current_user.id, current_user.timezone], expires_in: 1.hours do %>
|
||||
<%= cache ["activity_graph", current_user.id, current_user.timezone], expires_in: 1.minute do %>
|
||||
<div class="activity-graph-container">
|
||||
<div class="activity-graph">
|
||||
<% (365.days.ago.to_date..Time.current.to_date).to_a.each do |date| %>
|
||||
|
||||
@@ -43,6 +43,15 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>GitHub Account</h2>
|
||||
<% if @user.github_uid.present? %>
|
||||
<p>Your GitHub account is linked. <%= link_to "@#{@user.github_username}", "https://github.com/#{@user.github_username}", target: "_blank" %></p>
|
||||
<% else %>
|
||||
<%= link_to "Link GitHub Account", github_auth_path, class: "button" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Slack notifications</h2>
|
||||
<% if @enabled_sailors_logs.any? %>
|
||||
|
||||
@@ -33,6 +33,10 @@ Rails.application.configure do
|
||||
cache_home_stats: {
|
||||
cron: "0/10 * * * *",
|
||||
class: "CacheHomeStatsJob"
|
||||
},
|
||||
scan_github_repos: {
|
||||
cron: "0 10 * * *",
|
||||
class: "ScanGithubReposJob"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
@@ -42,6 +42,8 @@ Rails.application.routes.draw do
|
||||
# Auth routes
|
||||
get "/auth/slack", to: "sessions#new", as: :slack_auth
|
||||
get "/auth/slack/callback", to: "sessions#create"
|
||||
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
|
||||
get "/auth/token/:token", to: "sessions#token", as: :auth_token
|
||||
delete "signout", to: "sessions#destroy", as: "signout"
|
||||
|
||||
8
db/migrate/20250319165910_add_git_hub_fields_to_users.rb
Normal file
8
db/migrate/20250319165910_add_git_hub_fields_to_users.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class AddGitHubFieldsToUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :github_uid, :string
|
||||
add_column :users, :github_avatar_url, :string
|
||||
add_column :users, :github_access_token, :text
|
||||
add_column :users, :github_username, :string
|
||||
end
|
||||
end
|
||||
6
db/schema.rb
generated
6
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_03_19_142656) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_19_165910) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
@@ -244,6 +244,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_19_142656) do
|
||||
t.text "slack_access_token"
|
||||
t.integer "hackatime_extension_text_type", default: 0, null: false
|
||||
t.string "timezone", default: "UTC"
|
||||
t.string "github_uid"
|
||||
t.string "github_avatar_url"
|
||||
t.text "github_access_token"
|
||||
t.string "github_username"
|
||||
t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true
|
||||
t.index ["timezone"], name: "index_users_on_timezone"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user