mirror of
https://github.com/SrIzan10/hc-harbor.git
synced 2026-05-01 10:45:21 +00:00
data deletion oneshot (#691)
This commit is contained in:
@@ -5,8 +5,6 @@ body {
|
|||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 250px;
|
|
||||||
max-width: calc(100% - 250px);
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 100px;
|
margin-bottom: 100px;
|
||||||
transition: margin-left 0.3s ease, max-width 0.3s ease;
|
transition: margin-left 0.3s ease, max-width 0.3s ease;
|
||||||
|
|||||||
35
app/controllers/admin/deletion_requests_controller.rb
Normal file
35
app/controllers/admin/deletion_requests_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
class Admin::DeletionRequestsController < Admin::BaseController
|
||||||
|
before_action :set_deletion_request, only: [ :show, :approve, :reject ]
|
||||||
|
before_action :require_admin, only: [ :index, :show, :approve, :reject ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@pending = DeletionRequest.pending.includes(:user).order(requested_at: :asc)
|
||||||
|
@approved = DeletionRequest.approved.includes(:user, :admin_approved_by).order(scheduled_deletion_at: :asc)
|
||||||
|
@done = DeletionRequest.completed.includes(:user, :admin_approved_by).order(completed_at: :desc).limit(25)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve
|
||||||
|
@deletion_request.approve!(current_user)
|
||||||
|
redirect_to admin_deletion_requests_path, notice: "they gonna go kerblam on #{@deletion_request.scheduled_deletion_at.strftime('%B %d, %Y')}."
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject
|
||||||
|
@deletion_request.cancel!
|
||||||
|
redirect_to admin_deletion_requests_path, notice: "ratioed + stay mad"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_deletion_request
|
||||||
|
@deletion_request = DeletionRequest.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_admin
|
||||||
|
unless current_user && current_user.admin_level.in?([ "superadmin" ])
|
||||||
|
redirect_to root_path, alert: "no perms lmaooo"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
class Api::Hackatime::V1::HackatimeController < ApplicationController
|
class Api::Hackatime::V1::HackatimeController < ApplicationController
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
skip_before_action :enforce_lockout
|
||||||
|
before_action :check_lockout, only: [ :push_heartbeats ]
|
||||||
before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ], if: :is_blank?
|
before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ], if: :is_blank?
|
||||||
|
|
||||||
def push_heartbeats
|
def push_heartbeats
|
||||||
@@ -309,6 +311,11 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
|||||||
Rails.logger.error("Error queuing heartbeat public activity: #{e.class.name} #{e.message}")
|
Rails.logger.error("Error queuing heartbeat public activity: #{e.class.name} #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_lockout
|
||||||
|
return unless @user&.pending_deletion?
|
||||||
|
render json: { error: "Account pending deletion" }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
api_header = request.headers["Authorization"]
|
api_header = request.headers["Authorization"]
|
||||||
raw_token = api_header&.split(" ")&.last
|
raw_token = api_header&.split(" ")&.last
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base
|
|||||||
before_action :try_rack_mini_profiler_enable
|
before_action :try_rack_mini_profiler_enable
|
||||||
before_action :track_request
|
before_action :track_request
|
||||||
before_action :set_public_activity
|
before_action :set_public_activity
|
||||||
|
before_action :enforce_lockout
|
||||||
after_action :track_action
|
after_action :track_action
|
||||||
|
|
||||||
around_action :switch_time_zone, if: :current_user
|
around_action :switch_time_zone, if: :current_user
|
||||||
@@ -64,6 +65,12 @@ class ApplicationController < ActionController::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enforce_lockout
|
||||||
|
return unless current_user&.pending_deletion?
|
||||||
|
return if %w[deletion_requests sessions].include?(controller_name)
|
||||||
|
redirect_to deletion_path
|
||||||
|
end
|
||||||
|
|
||||||
def initialize_cache_counters
|
def initialize_cache_counters
|
||||||
Thread.current[:cache_hits] = 0
|
Thread.current[:cache_hits] = 0
|
||||||
Thread.current[:cache_misses] = 0
|
Thread.current[:cache_misses] = 0
|
||||||
|
|||||||
39
app/controllers/deletion_requests_controller.rb
Normal file
39
app/controllers/deletion_requests_controller.rb
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
class DeletionRequestsController < ApplicationController
|
||||||
|
before_action :require_login
|
||||||
|
before_action :check_can_request, only: [ :create ]
|
||||||
|
|
||||||
|
def show
|
||||||
|
@deletion_request = current_user.active_deletion_request
|
||||||
|
redirect_to root_path, alert: "no request" unless @deletion_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@deletion_request = DeletionRequest.create_for_user!(current_user)
|
||||||
|
redirect_to deletion_path
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Sentry.capture_exception(e)
|
||||||
|
redirect_to my_settings_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel
|
||||||
|
@deletion_request = current_user.active_deletion_request
|
||||||
|
if @deletion_request&.can_be_cancelled?
|
||||||
|
@deletion_request.cancel!
|
||||||
|
redirect_to my_settings_path, notice: "Your deletion request has been cancelled!"
|
||||||
|
else
|
||||||
|
redirect_to deletion_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def require_login
|
||||||
|
redirect_to root_path, alert: "who?" unless current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_can_request
|
||||||
|
unless current_user.can_request_deletion?
|
||||||
|
redirect_to my_settings_path, alert: "You can't request deletion right now."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
18
app/javascript/controllers/account_deletion_controller.js
Normal file
18
app/javascript/controllers/account_deletion_controller.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
console.log("AccountDeletionController connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("AccountDeletionController#confirm called");
|
||||||
|
const modal = document.getElementById("account-deletion-confirm-modal");
|
||||||
|
if (modal) {
|
||||||
|
modal.dispatchEvent(new CustomEvent("modal:open", { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
console.error("Modal not found: account-deletion-confirm-modal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/jobs/process_account_deletions_job.rb
Normal file
20
app/jobs/process_account_deletions_job.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class ProcessAccountDeletionsJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
DeletionRequest.ready_for_deletion.find_each do |deletion_request|
|
||||||
|
Rails.logger.info "kerblamming ##{deletion_request.user_id}"
|
||||||
|
|
||||||
|
begin
|
||||||
|
AnonymizeUserService.call(deletion_request.user)
|
||||||
|
deletion_request.complete!
|
||||||
|
|
||||||
|
Rails.logger.info "kerblamed account ##{deletion_request.user_id}"
|
||||||
|
rescue StandardError => e
|
||||||
|
Sentry.capture_exception(e, extra: { user_id: deletion_request.user_id })
|
||||||
|
Rails.logger.error "failed to kerblam ##{deletion_request.user_id}: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
70
app/models/deletion_request.rb
Normal file
70
app/models/deletion_request.rb
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
class DeletionRequest < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :admin_approved_by, class_name: "User", optional: true
|
||||||
|
|
||||||
|
enum :status, {
|
||||||
|
pending: 0,
|
||||||
|
approved: 1,
|
||||||
|
cancelled: 2,
|
||||||
|
completed: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
validates :requested_at, presence: true
|
||||||
|
validate :user_not_banned_from_deletion, on: :create
|
||||||
|
|
||||||
|
scope :active, -> { where(status: [ :pending, :approved ]) }
|
||||||
|
scope :ready_for_deletion, -> { approved.where("scheduled_deletion_at <= ?", Time.current) }
|
||||||
|
|
||||||
|
def self.create_for_user!(user)
|
||||||
|
create!(
|
||||||
|
user: user,
|
||||||
|
requested_at: Time.current,
|
||||||
|
status: :pending
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve!(admin)
|
||||||
|
update!(
|
||||||
|
status: :approved,
|
||||||
|
admin_approved_by: admin,
|
||||||
|
admin_approved_at: Time.current,
|
||||||
|
scheduled_deletion_at: Time.current + 30.days # grace period, if shit changes, change this
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel!
|
||||||
|
update!(
|
||||||
|
status: :cancelled,
|
||||||
|
cancelled_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete!
|
||||||
|
update!(
|
||||||
|
status: :completed,
|
||||||
|
completed_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def days_until_deletion
|
||||||
|
return nil unless scheduled_deletion_at.present?
|
||||||
|
[ (scheduled_deletion_at.to_date - Date.current).to_i, 0 ].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_be_cancelled?
|
||||||
|
pending? || approved?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_not_banned_from_deletion
|
||||||
|
return unless user.present?
|
||||||
|
|
||||||
|
if user.red?
|
||||||
|
last_audit = user.trust_level_audit_logs.order(created_at: :desc).first
|
||||||
|
if last_audit && last_audit.created_at > 365.days.ago
|
||||||
|
errors.add(:base, "You can not request data deletion due to a recent ban")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,8 @@ class EmailAddress < ApplicationRecord
|
|||||||
enum :source, {
|
enum :source, {
|
||||||
signing_in: 0,
|
signing_in: 0,
|
||||||
github: 1,
|
github: 1,
|
||||||
slack: 2
|
slack: 2,
|
||||||
|
preserved_for_deletion: 3
|
||||||
}, prefix: true
|
}, prefix: true
|
||||||
|
|
||||||
before_validation :downcase_email
|
before_validation :downcase_email
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
has_many :trust_level_audit_logs, dependent: :destroy
|
has_many :trust_level_audit_logs, dependent: :destroy
|
||||||
has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy
|
has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy
|
||||||
|
has_many :deletion_requests, dependent: :destroy
|
||||||
|
has_many :deletion_approvals, class_name: "DeletionRequest", foreign_key: "admin_approved_by_id"
|
||||||
|
|
||||||
has_many :access_grants,
|
has_many :access_grants,
|
||||||
class_name: "Doorkeeper::AccessGrant",
|
class_name: "Doorkeeper::AccessGrant",
|
||||||
@@ -133,6 +135,24 @@ class User < ApplicationRecord
|
|||||||
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
|
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def active_deletion_request
|
||||||
|
deletion_requests.active.order(created_at: :desc).first
|
||||||
|
end
|
||||||
|
|
||||||
|
def pending_deletion?
|
||||||
|
active_deletion_request.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_request_deletion?
|
||||||
|
return false if pending_deletion?
|
||||||
|
return true unless red?
|
||||||
|
|
||||||
|
last_audit = trust_level_audit_logs.order(created_at: :desc).first
|
||||||
|
return true unless last_audit
|
||||||
|
|
||||||
|
last_audit.created_at <= 365.days.ago
|
||||||
|
end
|
||||||
|
|
||||||
if Rails.env.development?
|
if Rails.env.development?
|
||||||
def self.slow_find_by_email(email)
|
def self.slow_find_by_email(email)
|
||||||
# This is an n+1 query, but provided for developer convenience
|
# This is an n+1 query, but provided for developer convenience
|
||||||
|
|||||||
70
app/services/anonymize_user_service.rb
Normal file
70
app/services/anonymize_user_service.rb
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
class AnonymizeUserService
|
||||||
|
def self.call(user)
|
||||||
|
new(user).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(user)
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
preserve_emails_for_ban_tracking
|
||||||
|
anonymize_user_data
|
||||||
|
destroy_associated_records
|
||||||
|
invalidate_sessions
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user
|
||||||
|
|
||||||
|
def preserve_emails_for_ban_tracking
|
||||||
|
user.email_addresses.update_all(
|
||||||
|
user_id: user.id,
|
||||||
|
source: EmailAddress.sources[:preserved_for_deletion]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def anonymize_user_data
|
||||||
|
user.update!(
|
||||||
|
slack_uid: nil,
|
||||||
|
slack_username: nil,
|
||||||
|
slack_avatar_url: nil,
|
||||||
|
slack_access_token: nil,
|
||||||
|
slack_scopes: [],
|
||||||
|
slack_neighborhood_channel: nil,
|
||||||
|
github_uid: nil,
|
||||||
|
github_username: nil,
|
||||||
|
github_avatar_url: nil,
|
||||||
|
github_access_token: nil,
|
||||||
|
hca_id: nil,
|
||||||
|
hca_access_token: nil,
|
||||||
|
hca_scopes: [],
|
||||||
|
username: "deleted_user_#{user.id}",
|
||||||
|
uses_slack_status: false,
|
||||||
|
country_code: nil,
|
||||||
|
mailing_address_otc: nil,
|
||||||
|
deprecated_name: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_associated_records
|
||||||
|
user.api_keys.destroy_all
|
||||||
|
user.admin_api_keys.destroy_all
|
||||||
|
user.sign_in_tokens.destroy_all
|
||||||
|
user.email_verification_requests.destroy_all
|
||||||
|
user.wakatime_mirrors.destroy_all
|
||||||
|
user.project_repo_mappings.destroy_all
|
||||||
|
user.mailing_address&.destroy
|
||||||
|
user.heartbeats.destroy_all
|
||||||
|
|
||||||
|
user.access_grants.destroy_all
|
||||||
|
user.access_tokens.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalidate_sessions
|
||||||
|
user.sign_in_tokens.destroy_all
|
||||||
|
end
|
||||||
|
end
|
||||||
131
app/views/admin/deletion_requests/index.html.erb
Normal file
131
app/views/admin/deletion_requests/index.html.erb
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<div class="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
<header class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-white mb-2">gdpr nerds</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="border border-primary rounded-xl p-6 bg-dark">
|
||||||
|
<h2 class="text-2xl font-semibold text-yellow-400 mb-4">approval queue (<%= @pending.count %>)</h2>
|
||||||
|
<% if @pending.any? %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-700">
|
||||||
|
<th class="py-3 px-4">goober</th>
|
||||||
|
<th class="py-3 px-4">email</th>
|
||||||
|
<th class="py-3 px-4">date</th>
|
||||||
|
<th class="py-3 px-4">trust</th>
|
||||||
|
<th class="py-3 px-4">exec</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @pending.each do |request| %>
|
||||||
|
<tr class="border-b border-gray-800 hover:bg-gray-800/50">
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img src="<%= request.user.avatar_url %>" alt="Avatar" class="w-8 h-8 rounded-full">
|
||||||
|
<span class="text-white"><%= request.user.display_name %></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-gray-300"><%= request.user.email_addresses.first&.email || "N/A" %></td>
|
||||||
|
<td class="py-3 px-4 text-gray-300"><%= time_ago_in_words(request.requested_at) %> ago</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium
|
||||||
|
<%= case request.user.trust_level
|
||||||
|
when 'green' then 'bg-green-600/20 text-green-400'
|
||||||
|
when 'yellow' then 'bg-yellow-600/20 text-yellow-400'
|
||||||
|
when 'red' then 'bg-red-600/20 text-red-400'
|
||||||
|
else 'bg-blue-600/20 text-blue-400'
|
||||||
|
end %>">
|
||||||
|
<%= request.user.trust_level %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<%= button_to "yuh", approve_admin_deletion_request_path(request),
|
||||||
|
method: :post,
|
||||||
|
class: "px-3 py-1 bg-green-600 hover:bg-green-500 text-white text-sm font-medium rounded transition-colors cursor-pointer" %>
|
||||||
|
<%= button_to "nah", reject_admin_deletion_request_path(request),
|
||||||
|
method: :post,
|
||||||
|
class: "px-3 py-1 bg-red-600 hover:bg-red-500 text-white text-sm font-medium rounded transition-colors cursor-pointer",
|
||||||
|
data: { confirm: "yo " } %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-400">nuthing here</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-primary rounded-xl p-6 bg-dark">
|
||||||
|
<h2 class="text-2xl font-semibold text-red-400 mb-4">accounts waiting to go kerblam (<%= @approved.count %>)</h2>
|
||||||
|
<% if @approved.any? %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-700">
|
||||||
|
<th class="py-3 px-4">goober</th>
|
||||||
|
<th class="py-3 px-4">approver</th>
|
||||||
|
<th class="py-3 px-4">approved</th>
|
||||||
|
<th class="py-3 px-4">exploded</th>
|
||||||
|
<th class="py-3 px-4">eta</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @approved.each do |request| %>
|
||||||
|
<tr class="border-b border-gray-800 hover:bg-gray-800/50">
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img src="<%= request.user.avatar_url %>" alt="Avatar" class="w-8 h-8 rounded-full">
|
||||||
|
<span class="text-white"><%= request.user.display_name %></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-gray-300"><%= request.admin_approved_by&.display_name || "N/A" %></td>
|
||||||
|
<td class="py-3 px-4 text-gray-300"><%= request.admin_approved_at&.strftime("%b %d, %Y") %></td>
|
||||||
|
<td class="py-3 px-4 text-red-400"><%= request.scheduled_deletion_at&.strftime("%b %d, %Y") %></td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium bg-red-600/20 text-red-400">
|
||||||
|
<%= request.days_until_deletion %> days
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-400">nuthing here</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-primary rounded-xl p-6 bg-dark">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-400 mb-4">recently kerblamed</h2>
|
||||||
|
<% if @done.any? %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-700">
|
||||||
|
<th class="py-3 px-4">goober</th>
|
||||||
|
<th class="py-3 px-4">approver</th>
|
||||||
|
<th class="py-3 px-4">kerblamed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @done.each do |request| %>
|
||||||
|
<tr class="border-b border-gray-800 hover:bg-gray-800/50">
|
||||||
|
<td class="py-3 px-4 text-gray-300">#<%= request.user_id %></td>
|
||||||
|
<td class="py-3 px-4 text-gray-300"><%= request.admin_approved_by&.display_name || "N/A" %></td>
|
||||||
|
<td class="py-3 px-4 text-gray-300"><%= request.completed_at&.strftime("%b %d, %Y at %I:%M %p") %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-400">nuthing here</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
77
app/views/deletion_requests/show.html.erb
Normal file
77
app/views/deletion_requests/show.html.erb
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<% content_for :title do %>
|
||||||
|
Account Deletion Pending
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% content_for :hide_nav, true %>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center p-6">
|
||||||
|
<div class="max-w-2xl w-full space-y-6">
|
||||||
|
<header class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-white mb-2">Account Scheduled for Deletion</h1>
|
||||||
|
<p class="text-muted text-lg">Your account is scheduled to self-destruct soon.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="border border-primary rounded-xl p-6 bg-dark">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-300">Status</span>
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
<%= @deletion_request.approved? ? 'bg-green-600/20 text-green-400' : 'bg-yellow-600/20 text-yellow-400' %>">
|
||||||
|
<%= @deletion_request.status.humanize %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between ">
|
||||||
|
<span class="text-gray-300">Requested</span>
|
||||||
|
<span class="text-white"><%= @deletion_request.requested_at.strftime("%B %d, %Y at %I:%M %p") %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @deletion_request.pending? %>
|
||||||
|
<div class="p-4 bg-yellow-900/30 border border-yellow-700 rounded">
|
||||||
|
<p class="text-yellow-200">
|
||||||
|
Your deletion request is pending approval. During this time, we will review your request and get back to you as soon as possible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% elsif @deletion_request.approved? %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-300">Deletion Date</span>
|
||||||
|
<span class="text-red-400 font-semibold">
|
||||||
|
<%= @deletion_request.scheduled_deletion_at.strftime("%B %d, %Y") %>
|
||||||
|
(<%= @deletion_request.days_until_deletion %> days remaining)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-red-900/30 border border-red-700 rounded">
|
||||||
|
<p class="text-red-200">
|
||||||
|
Your account will be permanently deleted on <%= @deletion_request.scheduled_deletion_at.strftime("%B %d, %Y") %>.
|
||||||
|
After deletion, your email address will be retained on file, but all other personal information will be removed or anonymized.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h3 class="text-white font-medium mb-2">Important Information</h3>
|
||||||
|
<ul class="text-gray-300 text-sm space-y-2">
|
||||||
|
<li>• During the 30-day waiting period, you cannot upload data, download data, or use your account for Hack Club programs.</li>
|
||||||
|
<li>• You can cancel this request at any time before the deletion date.</li>
|
||||||
|
<li>• After deletion, your email address will be retained to prevent ban evasion.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid grid-cols-2 gap-3 items-center">
|
||||||
|
<%= link_to signout_path, method: :delete, class: "w-full inline-flex justify-center px-4 py-3 border border-gray-600 text-gray-300 hover:bg-darkless font-medium rounded text-center transition-colors duration-200" do %>
|
||||||
|
Return to Login
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @deletion_request.can_be_cancelled? %>
|
||||||
|
<%= button_to "I changed my mind",
|
||||||
|
cancel_deletion_path,
|
||||||
|
method: :delete,
|
||||||
|
form: { class: "w-full" },
|
||||||
|
class: "w-full px-4 py-3 bg-primary hover:bg-red-600 text-white font-medium rounded text-center transition-colors duration-200 cursor-pointer" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="w-full"></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -177,22 +177,22 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="<%= content_for(:body_class) %> flex min-h-screen bg-darker" data-controller="nav">
|
<body class="<%= content_for(:body_class) %> flex min-h-screen bg-darker" data-controller="nav">
|
||||||
<button class="mobile-nav-button"
|
<% unless content_for?(:hide_nav) %>
|
||||||
data-action="click->nav#toggle"
|
<button class="mobile-nav-button"
|
||||||
data-nav-target="button"
|
data-action="click->nav#toggle"
|
||||||
aria-label="Toggle navigation menu"
|
data-nav-target="button"
|
||||||
aria-expanded="false">
|
aria-label="Toggle navigation menu"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
aria-expanded="false">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
<div class="nav-overlay" data-nav-target="overlay" data-action="click->nav#close"></div>
|
<div class="nav-overlay" data-nav-target="overlay" data-action="click->nav#close"></div>
|
||||||
|
<%= render "shared/nav" %>
|
||||||
<%= render "shared/nav" %>
|
<% end %>
|
||||||
|
|
||||||
<!-- 250px is defined in nav.css -->
|
<!-- 250px is defined in nav.css -->
|
||||||
<main class="flex-1 lg:ml-[250px] lg:max-w-[calc(100%-250px)] p-5 mb-[100px] pt-16 lg:pt-5 transition-all duration-300 ease-in-out">
|
<main class="flex-1 <%= 'lg:ml-[250px] lg:max-w-[calc(100%-250px)]' unless content_for?(:hide_nav) %> p-5 mb-[100px] pt-16 lg:pt-5 transition-all duration-300 ease-in-out">
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
<footer class="relative w-full mt-12 mb-5 p-2.5 text-center text-xs text-gray-600 hover:text-gray-300 transition-colors duration-200">
|
<footer class="relative w-full mt-12 mb-5 p-2.5 text-center text-xs text-gray-600 hover:text-gray-300 transition-colors duration-200">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -166,6 +166,31 @@
|
|||||||
<p class="text-xs text-gray-400">When enabled, others can view your coding statistics through public APIs. Many Hack Club YSWS programs use this to track your progress. Disabling this can prevent you from participating in some programs.</p>
|
<p class="text-xs text-gray-400">When enabled, others can view your coding statistics through public APIs. Many Hack Club YSWS programs use this to track your progress. Disabling this can prevent you from participating in some programs.</p>
|
||||||
<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200 cursor-pointer" %>
|
<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200 cursor-pointer" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-700 pt-4 mt-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-red-600/10 rounded">
|
||||||
|
<span class="text-2xl">🗑️</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-white" id="delete_account">Delete Account</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @user.can_request_deletion? %>
|
||||||
|
<p class="text-gray-300 text-sm">
|
||||||
|
Permanently delete your account and all associated data. This action cannot be undone after the 30-day grace period.
|
||||||
|
</p>
|
||||||
|
<button type="button"
|
||||||
|
data-controller="account-deletion"
|
||||||
|
data-action="click->account-deletion#confirm"
|
||||||
|
class="w-full px-4 py-2 bg-primary text-white font-medium rounded cursor-pointer">
|
||||||
|
Request Account Deletion
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-white text-sm">
|
||||||
|
Due to your account standing, you cannot request account deletion at this time. Reach out in #hackatime-v2 if this is a mistake.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
|
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
|
||||||
@@ -592,3 +617,29 @@
|
|||||||
],
|
],
|
||||||
custom: '<div class="w-full mb-4"><div class="bg-darker rounded-lg p-3"><code id="new-api-key-display" class="text-sm text-white break-all" data-token=""></code></div></div>' %>
|
custom: '<div class="w-full mb-4"><div class="bg-darker rounded-lg p-3"><code id="new-api-key-display" class="text-sm text-white break-all" data-token=""></code></div></div>' %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<% if @user.can_request_deletion? %>
|
||||||
|
<div data-controller="account-deletion">
|
||||||
|
<%= render "shared/modal",
|
||||||
|
modal_id: "account-deletion-confirm-modal",
|
||||||
|
title: "Delete Your Account?",
|
||||||
|
description: "This will permanently delete your account after a 30 day waiting period. During this time, you won't be able to use your account for any Hack Club programs.",
|
||||||
|
icon_svg: '<path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
|
||||||
|
icon_color: "text-primary",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
class: "border border-gray-600 text-gray-300 hover:bg-darkless",
|
||||||
|
action: "click->modal#close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete My Account",
|
||||||
|
class: "bg-primary text-white hover:bg-red-600 font-medium",
|
||||||
|
form: true,
|
||||||
|
url: create_deletion_path,
|
||||||
|
method: "post"
|
||||||
|
}
|
||||||
|
] %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ Rails.application.configure do
|
|||||||
cleanup_successful_jobs: {
|
cleanup_successful_jobs: {
|
||||||
cron: "0 0 * * *",
|
cron: "0 0 * * *",
|
||||||
class: "CleanupSuccessfulJobsJob"
|
class: "CleanupSuccessfulJobsJob"
|
||||||
|
},
|
||||||
|
process_account_deletions: {
|
||||||
|
cron: "0 2 * * *",
|
||||||
|
class: "ProcessAccountDeletionsJob",
|
||||||
|
description: "nuke accounts after 30 days"
|
||||||
}
|
}
|
||||||
# sync_stale_repo_metadata: {
|
# sync_stale_repo_metadata: {
|
||||||
# cron: "0 4 * * *", # Daily at 4 AM
|
# cron: "0 4 * * *", # Daily at 4 AM
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :trust_level_audit_logs, only: [ :index, :show ]
|
resources :trust_level_audit_logs, only: [ :index, :show ]
|
||||||
resources :admin_api_keys, except: [ :edit, :update ]
|
resources :admin_api_keys, except: [ :edit, :update ]
|
||||||
|
resources :deletion_requests, only: [ :index, :show ] do
|
||||||
|
member do
|
||||||
|
post :approve
|
||||||
|
post :reject
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
get "/impersonate/:id", to: "sessions#impersonate", as: :impersonate_user
|
get "/impersonate/:id", to: "sessions#impersonate", as: :impersonate_user
|
||||||
end
|
end
|
||||||
@@ -124,6 +130,10 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "deletion", to: "deletion_requests#show", as: :deletion
|
||||||
|
post "deletion", to: "deletion_requests#create", as: :create_deletion
|
||||||
|
delete "deletion", to: "deletion_requests#cancel", as: :cancel_deletion
|
||||||
|
|
||||||
get "my/wakatime_setup", to: "users#wakatime_setup"
|
get "my/wakatime_setup", to: "users#wakatime_setup"
|
||||||
get "my/wakatime_setup/step-2", to: "users#wakatime_setup_step_2"
|
get "my/wakatime_setup/step-2", to: "users#wakatime_setup_step_2"
|
||||||
get "my/wakatime_setup/step-3", to: "users#wakatime_setup_step_3"
|
get "my/wakatime_setup/step-3", to: "users#wakatime_setup_step_3"
|
||||||
|
|||||||
20
db/migrate/20251208020226_create_deletion_requests.rb
Normal file
20
db/migrate/20251208020226_create_deletion_requests.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateDeletionRequests < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :deletion_requests do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.datetime :requested_at, null: false
|
||||||
|
t.datetime :admin_approved_at
|
||||||
|
t.bigint :admin_approved_by_id
|
||||||
|
t.datetime :cancelled_at
|
||||||
|
t.datetime :scheduled_deletion_at
|
||||||
|
t.datetime :completed_at
|
||||||
|
t.integer :status, null: false, default: 0
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key :deletion_requests, :users, column: :admin_approved_by_id
|
||||||
|
add_index :deletion_requests, :status
|
||||||
|
add_index :deletion_requests, [ :user_id, :status ]
|
||||||
|
end
|
||||||
|
end
|
||||||
20
db/schema.rb
generated
20
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.1].define(version: 2025_12_05_211711) do
|
ActiveRecord::Schema[8.1].define(version: 2025_12_08_020226) do
|
||||||
create_schema "pganalyze"
|
create_schema "pganalyze"
|
||||||
# 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"
|
||||||
@@ -116,6 +116,22 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_05_211711) do
|
|||||||
t.index ["user_id"], name: "index_commits_on_user_id"
|
t.index ["user_id"], name: "index_commits_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "deletion_requests", force: :cascade do |t|
|
||||||
|
t.datetime "admin_approved_at"
|
||||||
|
t.bigint "admin_approved_by_id"
|
||||||
|
t.datetime "cancelled_at"
|
||||||
|
t.datetime "completed_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "requested_at", null: false
|
||||||
|
t.datetime "scheduled_deletion_at"
|
||||||
|
t.integer "status", default: 0, null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.index ["status"], name: "index_deletion_requests_on_status"
|
||||||
|
t.index ["user_id", "status"], name: "index_deletion_requests_on_user_id_and_status"
|
||||||
|
t.index ["user_id"], name: "index_deletion_requests_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "email_addresses", force: :cascade do |t|
|
create_table "email_addresses", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "email"
|
t.string "email"
|
||||||
@@ -609,6 +625,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_05_211711) do
|
|||||||
add_foreign_key "api_keys", "users"
|
add_foreign_key "api_keys", "users"
|
||||||
add_foreign_key "commits", "repositories"
|
add_foreign_key "commits", "repositories"
|
||||||
add_foreign_key "commits", "users"
|
add_foreign_key "commits", "users"
|
||||||
|
add_foreign_key "deletion_requests", "users"
|
||||||
|
add_foreign_key "deletion_requests", "users", column: "admin_approved_by_id"
|
||||||
add_foreign_key "email_addresses", "users"
|
add_foreign_key "email_addresses", "users"
|
||||||
add_foreign_key "email_verification_requests", "users"
|
add_foreign_key "email_verification_requests", "users"
|
||||||
add_foreign_key "heartbeats", "raw_heartbeat_uploads"
|
add_foreign_key "heartbeats", "raw_heartbeat_uploads"
|
||||||
|
|||||||
Reference in New Issue
Block a user