add support for admin api keys

This commit is contained in:
Echo
2025-07-01 12:14:55 -04:00
parent db4c834c0e
commit 256b0ab687
14 changed files with 626 additions and 1 deletions

View File

@@ -0,0 +1,41 @@
class Admin::AdminApiKeysController < Admin::BaseController
before_action :set_admin_api_key, only: [ :show, :destroy ]
def index
@admin_api_keys = AdminApiKey.includes(:user).active.order(created_at: :desc)
end
def show
end
def new
@admin_api_key = current_user.admin_api_keys.build
end
def create
@admin_api_key = current_user.admin_api_keys.build(admin_api_key_params)
if @admin_api_key.save
flash[:notice] = "created! now go have fun with it"
flash[:api_key_token] = @admin_api_key.token
redirect_to admin_admin_api_key_path(@admin_api_key)
else
render :new, status: :unprocessable_entity
end
end
def destroy
@admin_api_key.revoke!
redirect_to admin_admin_api_keys_path, notice: "the key has been revoked"
end
private
def set_admin_api_key
@admin_api_key = AdminApiKey.find(params[:id])
end
def admin_api_key_params
params.require(:admin_api_key).permit(:name)
end
end

View File

@@ -0,0 +1,40 @@
module Api
module Admin
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_admin_api_key!
private
def authenticate_admin_api_key!
authenticate_or_request_with_http_token do |token, options|
@admin_api_key = AdminApiKey.active.find_by(token: token)
if @admin_api_key
@current_user = @admin_api_key.user
@current_user.admin?
else
false
end
end
end
def current_user
@current_user
end
def current_admin_api_key
@admin_api_key
end
def render_unauthorized
render json: { error: "lmao no perms" }, status: :unauthorized
end
def render_forbidden
render json: { error: "lmao no perms" }, status: :forbidden
end
end
end
end

View File

@@ -0,0 +1,9 @@
module Api
module Admin
module V1
class ApplicationController < Api::Admin::ApplicationController
# todo, add shit here or something
end
end
end
end

View File

@@ -0,0 +1,74 @@
module Api
module Admin
module V1
class StatsController < Api::Admin::V1::ApplicationController
def index
render json: {
platform_stats: {
total_users: User.count,
admin_users: User.where(admin: true).count,
active_users_last_7_days: User.joins(:heartbeats)
.where(heartbeats: { time: 7.days.ago.. })
.distinct.count,
active_users_last_30_days: User.joins(:heartbeats)
.where(heartbeats: { time: 30.days.ago.. })
.distinct.count,
total_heartbeats: Heartbeat.count,
heartbeats_last_7_days: Heartbeat.where(time: 7.days.ago..).count,
heartbeats_last_30_days: Heartbeat.where(time: 30.days.ago..).count,
total_api_keys: ApiKey.count,
total_admin_api_keys: AdminApiKey.active.count,
trust_levels: User.group(:trust_level).count
},
generated_at: Time.current
}
end
def heartbeats
limit = [ params[:limit]&.to_i || 100, 1000 ].min
offset = params[:offset]&.to_i || 0
heartbeats = Heartbeat.includes(:user)
.order(time: :desc)
.limit(limit)
.offset(offset)
if params[:user_id].present?
heartbeats = heartbeats.where(user_id: params[:user_id])
end
if params[:from].present?
heartbeats = heartbeats.where("time >= ?", Time.parse(params[:from]))
end
if params[:to].present?
heartbeats = heartbeats.where("time <= ?", Time.parse(params[:to]))
end
render json: {
heartbeats: heartbeats.map do |hb|
{
id: hb.id,
user_id: hb.user_id,
user_display_name: hb.user&.display_name,
time: hb.time,
project: hb.project,
language: hb.language,
entity: hb.entity,
duration: hb.duration,
is_debugging: hb.is_debugging
}
end,
meta: {
limit: limit,
offset: offset,
total_count: heartbeats.count
}
}
rescue ArgumentError => e
render json: { error: "tf is that date buddy #{e.message}" }, status: :bad_request
end
end
end
end
end

View File

@@ -0,0 +1,120 @@
module Api
module Admin
module V1
class UsersController < Api::Admin::V1::ApplicationController
def index
users = User.includes(:email_addresses, :api_keys)
.order(created_at: :desc)
.limit(params[:limit]&.to_i || 50)
if params[:admin_only] == "true"
users = users.where(admin: true)
end
render json: {
users: users.map do |user|
{
id: user.id,
username: user.username,
display_name: user.display_name,
slack_uid: user.slack_uid,
github_username: user.github_username,
admin: user.admin?,
superadmin: user.superadmin?,
trust_level: user.trust_level,
timezone: user.timezone,
created_at: user.created_at,
last_heartbeat_at: user.heartbeats.maximum(:time),
api_keys_count: user.api_keys.count,
total_heartbeats: user.heartbeats.count
}
end,
meta: {
total_count: users.count,
limit: params[:limit]&.to_i || 50
}
}
end
def show
user = User.find(params[:id])
render json: {
user: {
id: user.id,
username: user.username,
display_name: user.display_name,
slack_uid: user.slack_uid,
slack_username: user.slack_username,
github_username: user.github_username,
admin: user.admin?,
superadmin: user.superadmin?,
trust_level: user.trust_level,
timezone: user.timezone,
country_code: user.country_code,
created_at: user.created_at,
updated_at: user.updated_at,
last_heartbeat_at: user.heartbeats.maximum(:time),
api_keys: user.api_keys.map do |key|
{
id: key.id,
name: key.name,
token_preview: "#{key.token[0..8]}...",
created_at: key.created_at
}
end,
email_addresses: user.email_addresses.map(&:email),
stats: {
total_heartbeats: user.heartbeats.count,
total_coding_time: user.heartbeats.sum(:duration) || 0,
languages_used: user.heartbeats.distinct.pluck(:language).compact.count,
projects_worked_on: user.heartbeats.distinct.pluck(:project).compact.count,
days_active: user.heartbeats.distinct.pluck(:date).compact.count
}
}
}
rescue ActiveRecord::RecordNotFound
render json: { error: "who tf is that lmao" }, status: :not_found
end
def update_trust_level
user = User.find(params[:id])
trust_level = params[:trust_level]
reason = params[:reason] || "updated via the api"
notes = params[:notes]
unless User.trust_levels.key?(trust_level)
return render json: { error: "tf is that lmao" }, status: :unprocessable_entity
end
if trust_level == "red" && !current_user.can_convict_users?
return render json: { error: "you dont got perms for that lmao" }, status: :forbidden
end
success = user.set_trust(
trust_level,
changed_by_user: current_user,
reason: reason,
notes: notes
)
if success
render json: {
success: true,
message: "okay, gotcha, that user is now #{trust_level}",
user: {
id: user.id,
trust_level: user.trust_level,
updated_at: user.updated_at
}
}
else
render json: { error: "whomp that didnt work" }, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotFound
render json: { error: "who tf is that lmao" }, status: :not_found
end
end
end
end
end

View File

@@ -0,0 +1,25 @@
class AdminApiKey < ApplicationRecord
belongs_to :user
validates :token, presence: true, uniqueness: true
validates :name, presence: true, uniqueness: { scope: :user_id }
before_validation :generate_token!, on: :create
scope :active, -> { where(revoked_at: nil) }
def active?
revoked_at.nil?
end
def revoke!
update!(revoked_at: Time.current)
end
private
def generate_token!
# should be jazzy enough
self.token ||= "hka_#{SecureRandom.hex(32)}"
end
end

View File

@@ -73,6 +73,7 @@ class User < ApplicationRecord
class_name: "Hackatime::ProjectLabel"
has_many :api_keys
has_many :admin_api_keys, dependent: :destroy
has_one :sailors_log,
foreign_key: :slack_uid,

View File

@@ -0,0 +1,77 @@
<% content_for :title, "Admin API Keys" %>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">admin api keys</h1>
<p class="text-gray-400">fraud team is gonna foam at the mouth for this shit</p>
</div>
<%= link_to "spawn in a new key",
new_admin_admin_api_key_path,
class: "bg-primary hover:bg-red text-white px-4 py-2 rounded-lg font-medium transition-colors" %>
</div>
</div>
<div class="bg-dark rounded-lg overflow-hidden shadow-xl">
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="bg-darkless">
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">name</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">spawned by</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">spawned at</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">token</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">perform</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-950">
<% @admin_api_keys.each do |api_key| %>
<tr class="hover:bg-darkless">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-white">
<%= api_key.name %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<% if api_key.user.avatar_url %>
<img class="h-6 w-6 rounded-full mr-2" src="<%= api_key.user.avatar_url %>" alt="">
<% end %>
<div>
<div class="text-sm text-white"><%= api_key.user.display_name %></div>
<div class="text-xs text-gray-400">ID: <%= api_key.user.id %></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
<%= api_key.created_at.strftime("%b %d, %Y at %I:%M %p") %>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-xs text-white">
<%= api_key.token[0..12] %>...
</code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<%= link_to "inspect",
admin_admin_api_key_path(api_key),
class: "text-blue-400 hover:text-blue-300" %>
<%= link_to "nuke",
admin_admin_api_key_path(api_key),
data: { "turbo-method": :delete },
class: "text-red-400 hover:text-red-300" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% if @admin_api_keys.empty? %>
<div class="text-center py-12">
<div class="text-gray-400 text-lg mb-2">nothing here cuzo</div>
<p class="text-gray-500 mb-4">you can make a new key if you wanna, or not</p>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,49 @@
<% content_for :title, "spawn in a new key" %>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">spawn in a new key</h1>
<p class="text-gray-400">its geting real</p>
</div>
<%= link_to "← get me outta here",
admin_admin_api_keys_path,
class: "text-gray-400 hover:text-white" %>
</div>
</div>
<div class="bg-dark rounded-lg p-8">
<%= form_with model: [:admin, @admin_api_key], local: true, class: "space-y-6" do |f| %>
<% if @admin_api_key.errors.any? %>
<div class="bg-red-900/50 border border-red-500 rounded-lg p-4">
<h3 class="text-red-300 font-medium mb-2">you done did it, fix this or else</h3>
<ul class="text-red-200 text-sm space-y-1">
<% @admin_api_key.errors.full_messages.each do |message| %>
<li>• <%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= f.label :name, class: "block text-md font-medium text-white mb-2" %>
<%= f.text_field :name,
placeholder: "put down something good please",
class: "w-full px-3 py-2 bg-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary" %>
</div>
<div class="bg-yellow-900/30 border border-yellow-500/50 rounded-lg p-4">
<h3 class="text-yellow-300 font-medium mb-2">keep in mind this gives admin access, so dont go sharing it with your dog ya goober. we also track whatever you do, so dont go out banning people or heidi will pipebomb your sorry butt</h3>
</div>
<div class="flex justify-end space-x-4">
<%= link_to "fuck no imma head out",
admin_admin_api_keys_path,
class: "px-4 py-2 text-gray-400 hover:text-white" %>
<%= f.submit "okay lets do this thing",
class: "px-6 py-2 bg-primary hover:bg-red text-white rounded-lg font-medium transition-colors" %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,137 @@
<% content_for :title, "Admin API Key Details" %>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">lookin at <%= @admin_api_key.name %></h1>
<p class="text-gray-400">get the deets</p>
</div>
<%= link_to "← Back to API Keys",
admin_admin_api_keys_path,
class: "text-gray-400 hover:text-white" %>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="bg-dark rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">deets</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">name</label>
<div class="text-white"><%= @admin_api_key.name %></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">spawned by</label>
<div class="flex items-center">
<% if @admin_api_key.user.avatar_url %>
<img class="h-6 w-6 rounded-full mr-2" src="<%= @admin_api_key.user.avatar_url %>" alt="">
<% end %>
<div>
<div class="text-white"><%= @admin_api_key.user.display_name %></div>
<div class="text-xs text-gray-400">ID: <%= @admin_api_key.user.id %></div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">spawned at</label>
<div class="text-white"><%= @admin_api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">status</label>
<div class="text-green-400">live</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-600">
<%= link_to "nuke it",
admin_admin_api_key_path(@admin_api_key),
data: { "turbo-method": :delete },
class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors" %>
</div>
</div>
<div class="bg-dark rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">how 2 api</h2>
<div class="space-y-4">
<% if flash[:api_key_token] %>
<div class="bg-green-900/30 border border-green-500/50 rounded-lg p-4">
<h3 class="text-green-300 font-medium mb-2">heres your key!</h3>
<p class="text-green-200 text-sm mb-3">
copy it now, its not gonna be shown again silly
</p>
<div class="bg-gray-800 rounded p-3 mb-3">
<code class="block text-white text-sm break-all select-all">
<%= flash[:api_key_token] %>
</code>
</div>
<button onclick="copy('<%= flash[:api_key_token] %>')"
class="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm">
copy
</button>
</div>
<% end %>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">preview</label>
<code class="block bg-gray-800 px-3 py-2 rounded text-white text-sm">
<%= @admin_api_key.token[0..20] %>...
</code>
<% unless flash[:api_key_token] %>
<p class="text-xs text-gray-400 mt-1">
you cant see the thing again, we showed it when you created it ya doofus
</p>
<% end %>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">auth stuff</label>
<div class="bg-gray-800 rounded p-3">
<code class="text-white text-sm">
Authorization: Bearer YOUR_KEY_HERE
</code>
</div>
<p class="text-xs text-gray-400 mt-1">
replace <code>YOUR_KEY_HERE</code> with your actual api key, pass it as a header and your balling
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">domain</label>
<div class="bg-gray-800 rounded p-3">
<code class="text-blue-400 text-sm">
<%= request.protocol %><%= request.host_with_port %>/api/admin/v1/
</code>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function copy(text) {
navigator.clipboard.writeText(text).then(function() {
// Show temporary success message
const button = event.target;
const originalText = button.textContent;
button.textContent = 'Copied!';
button.classList.add('bg-green-500');
button.classList.remove('bg-green-600', 'hover:bg-green-700');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-500');
button.classList.add('bg-green-600', 'hover:bg-green-700');
}, 2000);
}).catch(function(err) {
console.error('Could not copy text: ', err);
alert('Failed to copy to clipboard. Please copy manually.');
});
}
</script>

View File

@@ -103,6 +103,11 @@
OAuth2 apps
<% end %>
<% end %>
<% admin_tool(nil, "div") do %>
<%= link_to admin_admin_api_keys_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_admin_api_keys_path) || request.path.start_with?('/admin/admin_api_keys') ? 'bg-primary/50 text-primary' : 'hover:bg-[#23272a]'}", data: { action: "click->nav#clickLink" } do %>
Admin API Keys
<% end %>
<% end %>
<% admin_tool(nil, "div") do %>
<%= link_to flipper_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(flipper_path) ? 'bg-primary/50 text-primary' : 'hover:bg-[#23272a]'}", data: { action: "click->nav#clickLink" } do %>
Feature Flags

View File

@@ -31,6 +31,7 @@ Rails.application.routes.draw do
get "ysws_reviews/:record_id", to: "ysws_reviews#show", as: :ysws_review
resources :trust_level_audit_logs, only: [ :index, :show ]
resources :admin_api_keys, except: [ :edit, :update ]
end
if Rails.env.development?
@@ -143,6 +144,20 @@ Rails.application.routes.draw do
end
end
# Admin-only API namespace
namespace :admin do
namespace :v1 do
get "stats", to: "stats#index"
get "heartbeats", to: "stats#heartbeats"
resources :users, only: [ :index, :show ] do
member do
patch :update_trust_level
end
end
end
end
# wakatime compatible summary
get "summary", to: "summary#index"

View File

@@ -0,0 +1,16 @@
class CreateAdminApiKeys < ActiveRecord::Migration[8.0]
def change
create_table :admin_api_keys do |t|
t.references :user, null: false, foreign_key: true
t.text :name, null: false
t.text :token, null: false
t.datetime :revoked_at
t.timestamps
end
add_index :admin_api_keys, :token, unique: true
add_index :admin_api_keys, [ :user_id, :name ], unique: true
end
end
w

18
db/schema.rb generated
View File

@@ -10,13 +10,25 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_30_000002) do
ActiveRecord::Schema[8.0].define(version: 2025_07_01_142553) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
create_table "admin_api_keys", force: :cascade do |t|
t.bigint "user_id", null: false
t.text "name", null: false
t.text "token", null: false
t.datetime "revoked_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["token"], name: "index_admin_api_keys_on_token", unique: true
t.index ["user_id", "name"], name: "index_admin_api_keys_on_user_id_and_name", unique: true
t.index ["user_id"], name: "index_admin_api_keys_on_user_id"
end
create_table "ahoy_events", force: :cascade do |t|
t.bigint "visit_id"
t.bigint "user_id"
@@ -272,6 +284,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_30_000002) do
t.datetime "finished_generating_at"
t.datetime "deleted_at"
t.integer "period_type", default: 0, null: false
t.integer "timezone_utc_offset"
t.integer "timezone_offset"
t.index ["start_date", "period_type", "timezone_offset"], name: "index_leaderboards_on_start_date_period_type_timezone_offset", where: "(deleted_at IS NULL)"
t.index ["start_date"], name: "index_leaderboards_on_start_date", where: "(deleted_at IS NULL)"
end
@@ -551,6 +566,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_30_000002) do
t.index ["user_id"], name: "index_wakatime_mirrors_on_user_id"
end
add_foreign_key "admin_api_keys", "users"
add_foreign_key "api_keys", "users"
add_foreign_key "commits", "repositories"
add_foreign_key "commits", "users"