From 4a470667b6290853dc553e734eeac2a9aff2956a Mon Sep 17 00:00:00 2001 From: ShyMike <122023566+ImShyMike@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:40:52 +0100 Subject: [PATCH 1/7] allow username overwriting --- app/models/user.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 407e6a4b..65367cb6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -372,8 +372,7 @@ def self.from_slack_token(code, redirect_uri) end user.slack_uid = data.dig("authed_user", "id") - user.username ||= user_data.dig("user", "profile", "username") - user.username ||= user_data.dig("user", "profile", "display_name_normalized") + user.username = user_data.dig("user", "profile", "username") || user_data.dig("user", "profile", "display_name_normalized") user.slack_username = user_data.dig("user", "profile", "username") user.slack_avatar_url = user_data.dig("user", "profile", "image_192") || user_data.dig("user", "profile", "image_72") @@ -427,7 +426,7 @@ def self.from_github_token(code, redirect_uri, current_user) # Update GitHub-specific fields current_user.github_uid = github_uid - current_user.username ||= user_data["login"] + current_user.username = user_data["login"] current_user.github_username = user_data["login"] current_user.github_avatar_url = user_data["avatar_url"] current_user.github_access_token = data["access_token"] From 7664daba45b06b612339bfddf1056ce4889f2df5 Mon Sep 17 00:00:00 2001 From: ShyMike <122023566+ImShyMike@users.noreply.github.com> Date: Sun, 19 Oct 2025 10:40:41 +0100 Subject: [PATCH 2/7] add custom display name support --- app/controllers/admin/timeline_controller.rb | 10 ++-- .../trust_level_audit_logs_controller.rb | 4 +- .../api/admin/v1/admin_controller.rb | 16 +++---- .../api/v1/external_slack_controller.rb | 4 +- app/controllers/sessions_controller.rb | 2 +- app/controllers/static_pages_controller.rb | 2 +- app/controllers/users_controller.rb | 41 ++++++++++------ app/jobs/cache/currently_hacking_job.rb | 4 +- app/models/user.rb | 47 +++++++++++++++---- app/views/admin/timeline/show.html.erb | 2 +- app/views/shared/_user_mention.html.erb | 22 +++++---- app/views/users/edit.html.erb | 34 ++++++++++++-- db/migrate/20251018181955_add_custom_name.rb | 5 ++ db/schema.rb | 3 +- db/seeds.rb | 5 +- lib/flavor_text.rb | 4 +- lib/test_wakatime_service.rb | 2 +- lib/wakatime_service.rb | 2 +- 18 files changed, 145 insertions(+), 64 deletions(-) create mode 100644 db/migrate/20251018181955_add_custom_name.rb diff --git a/app/controllers/admin/timeline_controller.rb b/app/controllers/admin/timeline_controller.rb index 64ae526c..846a2d55 100644 --- a/app/controllers/admin/timeline_controller.rb +++ b/app/controllers/admin/timeline_controller.rb @@ -135,7 +135,7 @@ def show # For Stimulus: provide initial selected users with details @initial_selected_user_objects = User.where(id: @selected_user_ids) - .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) + .select(:id, :custom_name, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) .map { |u| { id: u.id, display_name: "#{u.display_name}", avatar_url: u.avatar_url } } .sort_by { |u_obj| @selected_user_ids.index(u_obj[:id]) || Float::INFINITY } # Preserve order @@ -185,10 +185,10 @@ def search_users avatar_url: user_id_match.avatar_url } ] else - users = User.where("LOWER(username) LIKE :query OR LOWER(slack_username) LIKE :query OR CAST(id AS TEXT) LIKE :query OR EXISTS (SELECT 1 FROM email_addresses WHERE email_addresses.user_id = users.id AND LOWER(email_addresses.email) LIKE :query)", query: "%#{query_term}%") - .order(Arel.sql("CASE WHEN LOWER(username) = #{ActiveRecord::Base.connection.quote(query_term)} THEN 0 ELSE 1 END, username ASC")) # Prioritize exact match + users = User.where("LOWER(custom_name) LIKE :query OR LOWER(slack_username) LIKE :query OR CAST(id AS TEXT) LIKE :query OR EXISTS (SELECT 1 FROM email_addresses WHERE email_addresses.user_id = users.id AND LOWER(email_addresses.email) LIKE :query)", query: "%#{query_term}%") + .order(Arel.sql("CASE WHEN LOWER(custom_name) = #{ActiveRecord::Base.connection.quote(query_term)} THEN 0 ELSE 1 END, username ASC")) # Prioritize exact match .limit(20) - .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) + .select(:id, :custom_name, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) results = users.map do |user| { @@ -218,7 +218,7 @@ def leaderboard_users all_ids_to_fetch.unshift(current_user.id).uniq! users_data = User.where(id: all_ids_to_fetch) - .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) + .select(:id, :custom_name, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) .index_by(&:id) final_user_objects = [] diff --git a/app/controllers/admin/trust_level_audit_logs_controller.rb b/app/controllers/admin/trust_level_audit_logs_controller.rb index a9257983..60959f55 100644 --- a/app/controllers/admin/trust_level_audit_logs_controller.rb +++ b/app/controllers/admin/trust_level_audit_logs_controller.rb @@ -25,7 +25,7 @@ def index if params[:user_search].present? search_term = params[:user_search].strip user_ids = User.joins(:email_addresses) - .where("LOWER(users.username) LIKE ? OR LOWER(users.slack_username) LIKE ? OR LOWER(users.github_username) LIKE ? OR LOWER(email_addresses.email) LIKE ? OR CAST(users.id AS TEXT) LIKE ?", + .where("LOWER(users.custom_name) LIKE ? OR LOWER(users.slack_username) LIKE ? OR LOWER(users.github_username) LIKE ? OR LOWER(email_addresses.email) LIKE ? OR CAST(users.id AS TEXT) LIKE ?", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term}%") .pluck(:id) @audit_logs = @audit_logs.where(user_id: user_ids) @@ -35,7 +35,7 @@ def index if params[:admin_search].present? search_term = params[:admin_search].strip admin_ids = User.joins(:email_addresses) - .where("LOWER(users.username) LIKE ? OR LOWER(users.slack_username) LIKE ? OR LOWER(users.github_username) LIKE ? OR LOWER(email_addresses.email) LIKE ? OR CAST(users.id AS TEXT) LIKE ?", + .where("LOWER(users.custom_name) LIKE ? OR LOWER(users.slack_username) LIKE ? OR LOWER(users.github_username) LIKE ? OR LOWER(email_addresses.email) LIKE ? OR CAST(users.id AS TEXT) LIKE ?", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term}%") .pluck(:id) @audit_logs = @audit_logs.where(changed_by_id: admin_ids) diff --git a/app/controllers/api/admin/v1/admin_controller.rb b/app/controllers/api/admin/v1/admin_controller.rb index b6a2f2ea..f95a8de6 100644 --- a/app/controllers/api/admin/v1/admin_controller.rb +++ b/app/controllers/api/admin/v1/admin_controller.rb @@ -15,7 +15,7 @@ def check }, creator: { id: creator.id, - username: creator.username, + username: creator.name, display_name: creator.display_name, admin_level: creator.admin_level } @@ -75,7 +75,7 @@ def user_info render json: { user: { id: user.id, - username: user.username, + username: user.name, display_name: user.display_name, slack_uid: user.slack_uid, slack_username: user.slack_username, @@ -118,7 +118,7 @@ def user_stats render json: { user_id: user.id, - username: user.username, + username: user.name, date: date.iso8601, timezone: user.timezone, heartbeats: heartbeats.map do |hb| @@ -184,7 +184,7 @@ def user_projects render json: { user_id: user.id, - username: user.username, + username: user.name, projects: project_data, total_projects: project_data.count } @@ -223,12 +223,12 @@ def user_convict message: "gotcha, updated to #{trust_level}", user: { id: user.id, - username: user.username, + username: user.name, trust_level: user.trust_level, updated_at: user.updated_at }, audit_log: { - changed_by: current_user.username, + changed_by: current_user.name, reason: reason, notes: notes, timestamp: Time.current @@ -276,7 +276,7 @@ def execute columns: columns, rows: rows, row_count: rows.count, - executed_by: current_user.username, + executed_by: current_user.name, executed_at: Time.current } rescue => e @@ -299,7 +299,7 @@ def trust_logs new_trust_level: log.new_trust_level, changed_by: { id: log.changed_by.id, - username: log.changed_by.username, + username: log.changed_by.name, display_name: log.changed_by.display_name, admin_level: log.changed_by.admin_level }, diff --git a/app/controllers/api/v1/external_slack_controller.rb b/app/controllers/api/v1/external_slack_controller.rb index 5218dda3..cc42dfc9 100644 --- a/app/controllers/api/v1/external_slack_controller.rb +++ b/app/controllers/api/v1/external_slack_controller.rb @@ -23,7 +23,7 @@ def create_user if user.persisted? return render json: { user_id: user.id, - username: user.username, + username: user.name, email: user.email_addresses.first&.email }, status: :ok end @@ -46,7 +46,7 @@ def create_user if user.save render json: { user_id: user.id, - username: user.username, + username: user.name, email: email }, status: :created else diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 2e632d9e..cf4e9fee 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -191,7 +191,7 @@ def impersonate session[:impersonater_user_id] ||= current_user.id session[:user_id] = user.id - redirect_to root_path, notice: "Impersonating #{user.username}" + redirect_to root_path, notice: "Impersonating #{user.name}" end def stop_impersonating diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 34f7f60f..224ff312 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -170,7 +170,7 @@ def currently_hacking json_response = locals[:users].map do |user| { id: user.id, - username: user.username, + username: user.name, slack_username: user.slack_username, github_username: user.github_username, display_name: user.display_name, diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 267ab0e7..6a450b6d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -6,17 +6,7 @@ class UsersController < ApplicationController before_action :require_admin, only: [ :update_trust_level ] def edit - @can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write") - - @enabled_sailors_logs = SailorsLogNotificationPreference.where( - slack_uid: @user.slack_uid, - enabled: true, - ).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS) - - @heartbeats_migration_jobs = @user.data_migration_jobs - - @projects = @user.project_repo_mappings.distinct.pluck(:project_name) - @work_time_stats_url = "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/#{@projects.first || 'example'}" + prepare_settings_page end def update @@ -29,8 +19,9 @@ def update redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user), notice: "Settings updated successfully" else - flash[:error] = "Failed to update settings" - render :settings, status: :unprocessable_entity + flash.now[:error] = @user.errors.full_messages.to_sentence.presence || "Failed to update settings" + prepare_settings_page + render :edit, status: :unprocessable_entity end elsif params[:default_timezone_leaderboard].present? if @user.update(default_timezone_leaderboard: params[:default_timezone_leaderboard] == "1") @@ -126,6 +117,21 @@ def require_current_user end end + def prepare_settings_page + @is_own_settings = is_own_settings? + @can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write") + + @enabled_sailors_logs = SailorsLogNotificationPreference.where( + slack_uid: @user.slack_uid, + enabled: true, + ).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS) + + @heartbeats_migration_jobs = @user.data_migration_jobs + + @projects = @user.project_repo_mappings.distinct.pluck(:project_name) + @work_time_stats_url = "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/#{@projects.first || 'example'}" + end + def set_user @user = if params["id"].present? User.find(params["id"]) @@ -141,6 +147,13 @@ def is_own_settings? end def user_params - params.require(:user).permit(:uses_slack_status, :hackatime_extension_text_type, :timezone, :allow_public_stats_lookup, :default_timezone_leaderboard) + params.require(:user).permit( + :uses_slack_status, + :hackatime_extension_text_type, + :timezone, + :allow_public_stats_lookup, + :default_timezone_leaderboard, + :custom_name, + ) end end diff --git a/app/jobs/cache/currently_hacking_job.rb b/app/jobs/cache/currently_hacking_job.rb index 74f2e7bd..bd4be025 100644 --- a/app/jobs/cache/currently_hacking_job.rb +++ b/app/jobs/cache/currently_hacking_job.rb @@ -29,9 +29,7 @@ def calculate users = users.sort_by do |user| [ active_projects[user.id].present? ? 0 : 1, - user.username.present? ? 0 : 1, - user.slack_username.present? ? 0 : 1, - user.github_username.present? ? 0 : 1 + user.name.present? ? 0 : 1 ] end diff --git a/app/models/user.rb b/app/models/user.rb index 65367cb6..f014ce86 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,15 +2,23 @@ class User < ApplicationRecord include TimezoneRegions include PublicActivity::Model + CUSTOM_NAME_MAX_LENGTH = 21 # going over 21 overflows the navbar + has_paper_trail after_create :create_signup_activity + before_validation :normalize_custom_name encrypts :slack_access_token, :github_access_token validates :slack_uid, uniqueness: true, allow_nil: true validates :github_uid, uniqueness: { conditions: -> { where.not(github_access_token: nil) } }, allow_nil: true validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }, allow_nil: false validates :country_code, inclusion: { in: ISO3166::Country.codes }, allow_nil: true + validates :custom_name, + length: { maximum: CUSTOM_NAME_MAX_LENGTH }, + format: { with: /\A[A-Za-z0-9_-]+\z/, message: "may only include letters, numbers, '-', and '_'" }, + allow_nil: true + validate :custom_name_must_be_visible attribute :allow_public_stats_lookup, :boolean, default: true attribute :default_timezone_leaderboard, :boolean, default: true @@ -242,8 +250,6 @@ def update_from_slack self.slack_username = profile.dig("username").presence self.slack_username ||= profile.dig("display_name_normalized").presence self.slack_username ||= profile.dig("real_name_normalized").presence - - self.username.blank? && self.username = self.slack_username end def update_slack_status @@ -372,8 +378,9 @@ def self.from_slack_token(code, redirect_uri) end user.slack_uid = data.dig("authed_user", "id") - user.username = user_data.dig("user", "profile", "username") || user_data.dig("user", "profile", "display_name_normalized") - user.slack_username = user_data.dig("user", "profile", "username") + user.slack_username = user_data.dig("username").presence + user.slack_username ||= user_data.dig("display_name_normalized").presence + user.slack_username ||= user_data.dig("real_name_normalized").presence user.slack_avatar_url = user_data.dig("user", "profile", "image_192") || user_data.dig("user", "profile", "image_72") user.parse_and_set_timezone(user_data.dig("user", "tz")) @@ -426,7 +433,6 @@ def self.from_github_token(code, redirect_uri, current_user) # Update GitHub-specific fields current_user.github_uid = github_uid - current_user.username = user_data["login"] current_user.github_username = user_data["login"] current_user.github_avatar_url = user_data["avatar_url"] current_user.github_access_token = data["access_token"] @@ -459,9 +465,7 @@ def avatar_url end def display_name - return slack_username.presence.truncate(10) if slack_username.present? - return github_username.presence.truncate(10) if github_username.present? - return username.presence.truncate(10) if username.present? + return name.presence.truncate(10) if name.present? # "zach@hackclub.com" -> "zach (email sign-up)" email = email_addresses&.first&.email @@ -470,6 +474,10 @@ def display_name email.split("@")&.first.truncate(10) + " (email sign-up)" end + def name + custom_name || slack_username || github_username + end + def most_recent_direct_entry_heartbeat heartbeats.where(source_type: :direct_entry).order(time: :desc).first end @@ -508,4 +516,27 @@ def invalidate_activity_graph_cache def create_signup_activity create_activity :first_signup, owner: self end + + def normalize_custom_name + original = custom_name + @custom_name_cleared_for_invisible = false + + return if original.nil? + + cleaned = original.gsub(/\p{Cf}/, "") + stripped = cleaned.strip + + if stripped.empty? + self.custom_name = nil + @custom_name_cleared_for_invisible = original.length.positive? + else + self.custom_name = stripped + end + end + + def custom_name_must_be_visible + if instance_variable_defined?(:@custom_name_cleared_for_invisible) && @custom_name_cleared_for_invisible + errors.add(:custom_name, "must include visible characters") + end + end end diff --git a/app/views/admin/timeline/show.html.erb b/app/views/admin/timeline/show.html.erb index da881341..a7c0e722 100644 --- a/app/views/admin/timeline/show.html.erb +++ b/app/views/admin/timeline/show.html.erb @@ -165,7 +165,7 @@
+ title="User ID: <%= user.id %> - <%= user.respond_to?(:name) && user.name.present? ? h(user.name) : h(user.email_addresses.first&.email) %> | Total Coded: <%= total_coded_time_seconds && total_coded_time_seconds > 0 ? short_time_detailed(total_coded_time_seconds) : '0m' %> | TZ: <%= h(user.timezone) %>">
<%= render "shared/user_mention", user: user %>
diff --git a/app/views/shared/_user_mention.html.erb b/app/views/shared/_user_mention.html.erb index b89b2d4a..869cc9c7 100644 --- a/app/views/shared/_user_mention.html.erb +++ b/app/views/shared/_user_mention.html.erb @@ -1,14 +1,23 @@ -
+