diff --git a/app/controllers/admin/timeline_controller.rb b/app/controllers/admin/timeline_controller.rb index 64ae526..846a2d5 100644 --- a/app/controllers/admin/timeline_controller.rb +++ b/app/controllers/admin/timeline_controller.rb @@ -135,7 +135,7 @@ class Admin::TimelineController < Admin::BaseController # 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 @@ class Admin::TimelineController < Admin::BaseController 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 @@ class Admin::TimelineController < Admin::BaseController 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 a925798..60959f5 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 @@ class Admin::TrustLevelAuditLogsController < Admin::BaseController 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 @@ class Admin::TrustLevelAuditLogsController < Admin::BaseController 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 b6a2f2e..f95a8de 100644 --- a/app/controllers/api/admin/v1/admin_controller.rb +++ b/app/controllers/api/admin/v1/admin_controller.rb @@ -15,7 +15,7 @@ module Api }, creator: { id: creator.id, - username: creator.username, + username: creator.name, display_name: creator.display_name, admin_level: creator.admin_level } @@ -75,7 +75,7 @@ module Api 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 @@ module Api 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 @@ module Api render json: { user_id: user.id, - username: user.username, + username: user.name, projects: project_data, total_projects: project_data.count } @@ -223,12 +223,12 @@ module Api 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 @@ module Api 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 @@ module Api 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 5218dda..cc42dfc 100644 --- a/app/controllers/api/v1/external_slack_controller.rb +++ b/app/controllers/api/v1/external_slack_controller.rb @@ -23,7 +23,7 @@ module Api 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 @@ module Api 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 2e632d9..cf4e9fe 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -191,7 +191,7 @@ class SessionsController < ApplicationController 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 34f7f60..224ff31 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -170,7 +170,7 @@ class StaticPagesController < ApplicationController 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 267ab0e..6a450b6 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 @@ class UsersController < ApplicationController 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 @@ class UsersController < ApplicationController 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 @@ class UsersController < ApplicationController 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 74f2e7b..bd4be02 100644 --- a/app/jobs/cache/currently_hacking_job.rb +++ b/app/jobs/cache/currently_hacking_job.rb @@ -29,9 +29,7 @@ class Cache::CurrentlyHackingJob < Cache::ActivityJob 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 65367cb..f014ce8 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 @@ class User < ApplicationRecord 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 @@ class User < ApplicationRecord 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 @@ class User < ApplicationRecord # 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 @@ class User < ApplicationRecord 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 @@ class User < ApplicationRecord 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 @@ class User < ApplicationRecord 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 da88134..a7c0e72 100644 --- a/app/views/admin/timeline/show.html.erb +++ b/app/views/admin/timeline/show.html.erb @@ -165,7 +165,7 @@