diff --git a/.gitignore b/.gitignore index d665bfb07..9c5e293a0 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ qpixel-import.tar.gz dump.rdb +# Ignore rbenv local file +.ruby-version + # Ignore IRB files .irbrc .irb_history diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 0f8967af6..c4de13371 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -65,6 +65,15 @@ $(document).on('ready', function() { }); }); + $(".is-partial-only:not(.open)").on("click", (evt) => { + if (evt.target.classList.contains("open")) { + return; + } + + evt.target.classList.add("open"); + evt.stopPropagation(); + }); + if (document.cookie.indexOf('dismiss_fvn') === -1 ) { $('#fvn-dismiss').on('click', () => { document.cookie = 'dismiss_fvn=true; path=/; expires=Fri, 31 Dec 9999 23:59:59 GMT'; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 44a2e1d21..541a3548a 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -269,3 +269,35 @@ img.header--item-image { .widget--header-link { margin: 0 0 0 0.5em; } + + +.is-partial-only:not(.open) { + max-height: 100px; + overflow: hidden; + position: relative; + + &::after { + content: ''; + position: absolute; + right: 0; left: 0; + bottom: 0; + height: 75px; + background-color: rgba(255, 255, 255, 0.5); + background: linear-gradient(#ffffff66, #fffffffa); + z-index: 10000000; + } + + &::before { + content: 'expand'; + position: absolute; + left: 50%; + transform: translate(-50%); + bottom: 10px; + z-index: 10000001; + padding: 5px 20px; + background-color: #ddd; + border: 1px solid #666; + border-radius: 15px; + cursor: pointer; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/tabs.scss b/app/assets/stylesheets/tabs.scss index 108168625..b3122a994 100644 --- a/app/assets/stylesheets/tabs.scss +++ b/app/assets/stylesheets/tabs.scss @@ -2,4 +2,8 @@ .tabs { margin-bottom: 1em; + + .tabs--push { + flex-grow: 1; + } } \ No newline at end of file diff --git a/app/assets/stylesheets/users.scss b/app/assets/stylesheets/users.scss index 867e962e3..ab4f19bb5 100644 --- a/app/assets/stylesheets/users.scss +++ b/app/assets/stylesheets/users.scss @@ -231,3 +231,19 @@ $sizes: (16, 32, 40, 48, 64, 128, 256); } } } + + +.modtools--sidebar { + margin-right: 1rem; +} +.modtools--usercard { + padding: 0.5rem; +} +.modtools-tbl-noborder { + th { + border-bottom-width: 1px !important; + width: 150px; + } +} + +.mod-warnings-clear-form { display: inline; } \ No newline at end of file diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 5d13f6d3b..1ac5b3392 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -2,7 +2,7 @@ class AdminController < ApplicationController before_action :verify_admin, except: [:change_back, :verify_elevation] before_action :verify_global_admin, only: [:admin_email, :send_admin_email, :new_site, :create_site, :setup, - :setup_save, :hellban] + :setup_save, :failban] before_action :verify_developer, only: [:change_users, :impersonate, :all_email, :send_all_email] def index; end @@ -143,7 +143,7 @@ def setup_save render end - def hellban + def failban @user = User.find params[:id] @user.block("user manually blocked by admin ##{current_user.id}") flash[:success] = t 'admin.user_fed_stat' @@ -152,6 +152,7 @@ def hellban def impersonate @user = User.find params[:id] + render layout: 'without_sidebar' end def change_users diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index efa5963ca..ff2c400b6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -299,7 +299,10 @@ def pull_categories def check_if_warning_or_suspension_pending return if current_user.nil? - warning = ModWarning.where(community_user: current_user.community_user, active: true).any? + warning = ModWarning.where(community_user: current_user.community_user) + .or(ModWarning.where(user: current_user, is_global: true)) + .where(active: true) + .any? return unless warning # Ignore devise and warning routes diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 436337828..53011013f 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -1,4 +1,5 @@ # Provides mainly web actions for using and making comments. + class CommentsController < ApplicationController before_action :authenticate_user!, except: [:post, :show, :thread] before_action :set_comment, only: [:update, :destroy, :undelete, :show] @@ -6,10 +7,12 @@ class CommentsController < ApplicationController before_action :check_privilege, only: [:update, :destroy, :undelete] before_action :check_if_target_post_locked, only: [:create, :post_follow] before_action :check_if_parent_post_locked, only: [:update, :destroy] + before_action :check_if_thread_is_private, only: [:update, :destroy, :undelete] + before_action :check_if_not_mod_and_thread_is_private, only: [:thread_rename, :thread_restrict, :thread_unrestrict] def create_thread @post = Post.find(params[:post_id]) - if @post.comments_disabled && !current_user.is_moderator && !current_user.is_admin + if @post.comments_disabled && !current_user.is_moderator render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: :forbidden return elsif !@post.can_access?(current_user) @@ -63,11 +66,13 @@ def create_thread def create @comment_thread = CommentThread.find(params[:id]) @post = @comment_thread.post - if @post.comments_disabled && !current_user.is_moderator && !current_user.is_admin - render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: :forbidden - return - elsif !@post.can_access?(current_user) - return not_found + unless @post.nil? + if !@post.can_access?(current_user) + return not_found + elsif @post.comments_disabled && !current_user.is_moderator + render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: :forbidden + return + end end body = params[:content] @@ -89,10 +94,13 @@ def create .where('link LIKE ?', "#{thread_url}%") next if existing_notification.exists? - title = @post.parent.nil? ? @post.title : @post.parent.title - follower.user.create_notification("There are new comments in a followed thread '#{@comment_thread.title}' " \ - "on the post '#{title}'", - helpers.comment_link(@comment)) + if @post.nil? + wording = "There are new comments in a private thread '#{@comment_thread.title}'" + else + post_title = @post.parent.nil? ? @post.title : @post.parent.title + wording = "There are new comments in a followed thread '#{@comment_thread.title}' on the post '#{post_title}'" + end + follower.user.create_notification(wording, helpers.comment_link(@comment)) end else flash[:danger] = @comment.errors.full_messages.join(', ') @@ -161,8 +169,7 @@ def thread end def thread_followers - return not_found unless @comment_thread.can_access?(current_user) - return not_found unless current_user.is_moderator || current_user.is_admin + return not_found unless current_user.is_moderator @followers = ThreadFollower.where(comment_thread: @comment_thread).joins(:user, user: :community_user) .includes(:user, user: [:community_user, :avatar_attachment]) @@ -292,7 +299,7 @@ def set_thread end def check_privilege - unless current_user.is_moderator || current_user.is_admin || current_user == @comment.user + unless current_user.is_moderator || current_user == @comment.user render template: 'errors/forbidden', status: :forbidden end end @@ -302,7 +309,7 @@ def check_if_parent_post_locked end def check_if_target_post_locked - check_if_locked(Post.find(params[:post_id])) + params[:post_id].present? && check_if_locked(Post.find(params[:post_id])) end def check_for_pings(thread, content) @@ -326,12 +333,14 @@ def apply_pings(pings) end def comment_rate_limited + return false if @comment_thread&.is_private + recent_comments = Comment.where(created_at: 24.hours.ago..DateTime.now, user: current_user).where \ .not(post: Post.includes(:parent).where(parents_posts: { user_id: current_user.id })) \ .where.not(post: Post.where(user_id: current_user.id)).count max_comments_per_day = SiteSetting[current_user.privilege?('unrestricted') ? 'RL_Comments' : 'RL_NewUserComments'] - if (!@post.user_id == current_user.id || @post&.parent&.user_id == current_user.id) \ + if (!@post&.user_id == current_user.id || @post&.parent&.user_id == current_user.id) \ && recent_comments >= max_comments_per_day comment_limit_msg = "You have used your daily comment limit of #{recent_comments} comments. " \ 'Come back tomorrow to continue commenting. Comments on own posts and on answers ' \ @@ -349,4 +358,17 @@ def comment_rate_limited end false end + + def check_if_thread_is_private + if @comment_thread&.is_private + flash[:danger] = 'This action is not permitted.' + redirect_to comment_thread_path(@comment_thread.id) + end + end + + def check_if_not_mod_and_thread_is_private + unless current_user.is_moderator + check_if_thread_is_private + end + end end diff --git a/app/controllers/mod_warning_controller.rb b/app/controllers/mod_warning_controller.rb index d8579a08d..0f36fcb3c 100644 --- a/app/controllers/mod_warning_controller.rb +++ b/app/controllers/mod_warning_controller.rb @@ -1,8 +1,9 @@ class ModWarningController < ApplicationController before_action :verify_moderator, only: [:log, :new, :create, :lift] + before_action :verify_global_moderator, only: [:new_global, :create_global] before_action :set_warning, only: [:current, :approve] - before_action :set_user, only: [:log, :new, :create, :lift] + before_action :set_user, only: [:log, :new, :create, :lift, :new_global, :create_global] def current render layout: 'without_sidebar' @@ -21,7 +22,20 @@ def approve end def log - @warnings = ModWarning.where(community_user: @user.community_user).order(created_at: :desc).all + warnings = ModWarning.where(community_user: @user.community_user) + if current_user.is_global_moderator || current_user.is_global_admin + warnings = warnings.or(ModWarning.where(user: @user, is_global: true)) + end + warnings = warnings.all + .map { |w| { type: :warning, value: w } } + + user_followed_threads = ThreadFollower.where(user: @user).select(:comment_thread_id) + messages = CommentThread.where(post: nil, is_private: true, id: user_followed_threads) + .all + .filter { |m| m.comments.first.user&.id != @user&.id } + .map { |m| { type: :message, value: m } } + + @entries = (warnings + messages).sort_by { |e| e[:value].created_at }.reverse render layout: 'without_sidebar' end @@ -44,25 +58,64 @@ def create @warning = ModWarning.new(author: current_user, community_user: @user.community_user, body: params[:mod_warning][:body], is_suspension: is_suspension, - suspension_end: suspension_end, active: true, read: false) + suspension_end: suspension_end, active: true, read: false, + is_global: false) if @warning.save if is_suspension @user.community_user.update(is_suspended: is_suspension, suspension_end: suspension_end, suspension_public_comment: params[:mod_warning][:suspension_public_notice]) end - redirect_to user_path(@user) + redirect_to mod_user_path(@user) + else + render :new + end + end + + def new_global + @warning = ModWarning.new(author: current_user, is_suspension: true) + render layout: 'without_sidebar' + end + + def create_global + suspension_duration = params[:mod_warning][:suspension_duration].to_i + + suspension_duration = 1 if suspension_duration <= 0 + suspension_duration = 365 if suspension_duration > 365 + + suspension_end = DateTime.now + suspension_duration.days + + @warning = ModWarning.new(author: current_user, user: @user, + body: params[:mod_warning][:body], is_suspension: true, + suspension_end: suspension_end, active: true, read: false, + is_global: true) + if @warning.save + @user.update(is_globally_suspended: true, global_suspension_end: suspension_end) + + redirect_to mod_user_path(@user) else render :new end end def lift - @warning = ModWarning.where(community_user: @user.community_user, active: true).last + @warning = ModWarning.where(user: @user, is_global: true, active: true).last + @warning ||= ModWarning.where(community_user: @user.community_user, active: true).last return not_found if @warning.nil? + if @warning.is_global && !current_user.is_global_moderator && !current_user.is_global_admin + flash[:error] = 'A network-wide suspension has been applied which may only be lifted by global moderators. ' \ + 'Community-wide suspensions which have been imposed before that global suspension cannot be ' \ + 'lifted at this time (which is probably why you are seeing this error).' + redirect_to mod_warning_log_path(@user) + end + @warning.update(active: false, read: false) - @user.community_user.update is_suspended: false, suspension_public_comment: nil, suspension_end: nil + if @warning.is_global + @user.update is_globally_suspended: false, global_suspension_end: nil + else + @user.community_user.update is_suspended: false, suspension_public_comment: nil, suspension_end: nil + end AuditLog.moderator_audit(event_type: 'warning_lift', related: @warning, user: current_user, comment: "<>") @@ -75,7 +128,8 @@ def lift private def set_warning - @warning = ModWarning.where(community_user: current_user.community_user, active: true).last + @warning = ModWarning.where(user: current_user, active: true, is_global: true).last + @warning ||= ModWarning.where(community_user: current_user.community_user, active: true).last not_found if @warning.nil? end diff --git a/app/controllers/moderator_controller.rb b/app/controllers/moderator_controller.rb index 04d2a94b5..252c9f901 100644 --- a/app/controllers/moderator_controller.rb +++ b/app/controllers/moderator_controller.rb @@ -67,6 +67,7 @@ def user_vote_summary total: Vote.where(recv_user: @user).count ) ) + render layout: 'without_sidebar' end private diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1b4aec6f0..32cd0a61c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -8,10 +8,16 @@ class UsersController < ApplicationController :qr_login_code, :me, :preferences, :set_preference, :my_vote_summary, :disconnect_sso, :confirm_disconnect_sso, :filters] before_action :verify_moderator, only: [:mod, :destroy, :soft_delete, :role_toggle, :full_log, - :annotate, :annotations, :mod_privileges, :mod_privilege_action] - before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :posts, :role_toggle, :full_log, :activity, - :annotate, :annotations, :mod_privileges, :mod_privilege_action, - :vote_summary, :network, :avatar] + :annotate, :annotations, :mod_privileges, + :mod_privilege_action, :mod_delete, :mod_reset_profile, + :mod_clear_profile, :mod_escalation, :mod_escalate, + :mod_contact, :mod_message] + before_action :verify_global_moderator, only: [:mod_failban, :global_log, :mod_delete_network_account] + before_action :set_user, only: [:activity, :annotate, :annotations, :avatar, :destroy, :full_log, :global_log, :mod, + :mod_clear_profile, :mod_contact, :mod_delete, :mod_delete_network_account, + :mod_escalate, :mod_escalation, :mod_failban, :mod_message, :mod_privilege_action, + :mod_privileges, :mod_reset_profile, :network, :posts, :role_toggle, :show, + :soft_delete, :vote_summary] before_action :check_deleted, only: [:show, :posts, :activity] def index @@ -242,7 +248,56 @@ def activity render layout: 'without_sidebar' end - def mod; end + def mod + render layout: 'without_sidebar' + end + + def mod_escalation + @flag = Flag.new(post_flag_type: nil, reason: '', post_id: @user.id, post_type: 'User', user: current_user) + render layout: 'without_sidebar' + end + + def mod_escalate + @flag = Flag.create(post_flag_type: nil, reason: params[:flag][:reason], post_id: @user.id, + post_type: 'User', user: current_user, escalated: true, + escalated_by: current_user, escalated_at: DateTime.now, + escalation_comment: '(escalated via Contact Community Team Tool)') + FlagMailer.with(flag: @flag).flag_escalated.deliver_now + flash[:success] = 'Thank you for your message. We have been notified and are looking into it.' + redirect_to mod_user_path(@user) + end + + def mod_contact + render layout: 'without_sidebar' + end + + def mod_message + title = params[:title] + unless title.present? + title = 'Private Moderator Message' + end + + body = params[:body] + + @comment_thread = CommentThread.new(title: title, post: nil, is_private: true) + @comment = Comment.new(post: nil, content: body, user: current_user, comment_thread: @comment_thread) + + success = ActiveRecord::Base.transaction do + @comment_thread.save! + @comment.save! + ThreadFollower.create! comment_thread: @comment_thread, user: @user + end + + if success + @user.create_notification("You have received a moderator message: #{@comment_thread.title}", + helpers.comment_link(@comment)) + redirect_to comment_thread_path(@comment_thread.id) + else + flash[:danger] = "Could not create comment thread: #{(@comment_thread.errors.full_messages \ + + @comment.errors.full_messages).join(', ')}" + render :mod_contact, layout: 'without_sidebar' + end + end def full_log @posts = Post.where(user: @user).count @@ -276,7 +331,8 @@ def full_log when 'interesting' Comment.where(user: @user, deleted: true).all + Flag.where(user: @user, status: 'declined').all + \ SuggestedEdit.where(user: @user, active: false, accepted: false).all + \ - Post.where(user: @user).where('score < 0.25 OR deleted=1').all + Post.where(user: @user).where('score < 0.25 OR deleted=1').all + \ + ModWarning.where(community_user: @user.community_user).all else Post.where(user: @user).all + Comment.where(user: @user).all + Flag.where(user: @user).all + \ SuggestedEdit.where(user: @user).all + PostHistory.where(user: @user).all + \ @@ -286,8 +342,88 @@ def full_log render layout: 'without_sidebar' end + def global_log + @posts = Post.unscoped.where(user: @user).count + @comments = Comment.unscoped.where(user: @user).count + @flags = Flag.unscoped.where(user: @user).count + @suggested_edits = SuggestedEdit.unscoped.where(user: @user).count + @edits = PostHistory.unscoped.where(user: @user).count + @mod_warnings_received = ModWarning.where(community_user: @user.community_users).count + \ + ModWarning.where(user: @user).count + + @all_edits = @suggested_edits + @edits + + @interesting_comments = Comment.unscoped.where(user: @user, deleted: true).count + @interesting_flags = Flag.unscoped.where(user: @user, status: 'declined').count + @interesting_edits = SuggestedEdit.unscoped.where(user: @user, active: false, accepted: false).count + @interesting_posts = Post.unscoped.where(user: @user).where('score < 0.25 OR deleted=1').count + + @interesting = @interesting_comments + @interesting_flags + @mod_warnings_received + \ + @interesting_edits + @interesting_posts + + @items = (case params[:filter] + when 'posts' + Post.unscoped.where(user: @user).all + when 'comments' + Comment.unscoped.where(user: @user).all + when 'flags' + Flag.unscoped.where(user: @user).all + when 'edits' + SuggestedEdit.unscoped.where(user: @user).all + \ + PostHistory.where(user: @user).all + when 'warnings' + ModWarning.where(community_user: @user.community_users).all + \ + ModWarning.where(user: @user).all + when 'interesting' + Comment.unscoped.where(user: @user, deleted: true).all + \ + Flag.unscoped.where(user: @user, status: 'declined').all + \ + SuggestedEdit.unscoped.where(user: @user, active: false, accepted: false).all + \ + Post.unscoped.where(user: @user).where('score < 0.25 OR deleted=1').all + \ + ModWarning.unscoped.where(community_user: @user.community_users).all + \ + ModWarning.where(user: @user).all + else + Post.unscoped.where(user: @user).all + \ + Comment.unscoped.where(user: @user).all + \ + Flag.unscoped.where(user: @user).all + \ + SuggestedEdit.unscoped.where(user: @user).all + \ + PostHistory.unscoped.where(user: @user).all + \ + ModWarning.unscoped.where(community_user: @user.community_users).all + \ + ModWarning.where(user: @user).all + end).sort_by(&:created_at).reverse + + render layout: 'without_sidebar' + end + def mod_privileges @abilities = Ability.all + render layout: 'without_sidebar' + end + + def mod_reset_profile + render layout: 'without_sidebar' + end + + def mod_clear_profile + before = @user.attributes_print + @user.update(username: "user#{@user.id}", profile: '', website: '', twitter: '', + profile_markdown: '', discord: '') + @user.create_notification('Your profile has been reset by a moderator. Click on this ' \ + 'notification to update your profile.', edit_user_profile_path) + AuditLog.moderator_audit(event_type: 'profile_clear', user: current_user, comment: "<>", + related: @user) + redirect_to mod_user_path(@user) + end + + def mod_delete + render layout: 'without_sidebar' + end + + def mod_delete_network_account + render layout: 'without_sidebar' + end + + def mod_failban + render layout: 'without_sidebar' end def destroy diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index 548a269c9..699c2e6d3 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -55,6 +55,7 @@ def render_comment_helpers(comment_text, user = current_user) def get_pingable(thread) post = thread.post + return [] unless post.present? # Detached threads do not support individual pings # post author + # answer authors + diff --git a/app/models/comment.rb b/app/models/comment.rb index 12dfd651f..2b902f079 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,10 +1,11 @@ # Represents a comment. Comments are attached to both a post and a user. class Comment < ApplicationRecord - include PostRelated + include CommunityRelated scope :deleted, -> { where(deleted: true) } scope :undeleted, -> { where(deleted: false) } + belongs_to :post, optional: true belongs_to :user belongs_to :comment_thread belongs_to :references_comment, class_name: 'Comment', optional: true diff --git a/app/models/comment_thread.rb b/app/models/comment_thread.rb index 39f5dcf5c..8e0d027dd 100644 --- a/app/models/comment_thread.rb +++ b/app/models/comment_thread.rb @@ -1,6 +1,7 @@ class CommentThread < ApplicationRecord - include PostRelated + include CommunityRelated + belongs_to :post, optional: true has_many :comments has_many :thread_follower belongs_to :locked_by, class_name: 'User', optional: true @@ -9,8 +10,8 @@ class CommentThread < ApplicationRecord scope :deleted, -> { where(deleted: true) } scope :undeleted, -> { where(deleted: false) } - scope :initially_visible, -> { where(deleted: false, archived: false).where('reply_count > 0') } - scope :publicly_available, -> { where(deleted: false).where('reply_count > 0') } + scope :initially_visible, -> { where(deleted: false, archived: false, is_private: false).where('reply_count > 0') } + scope :publicly_available, -> { where(deleted: false, is_private: false).where('reply_count > 0') } scope :archived, -> { where(archived: true) } after_create :create_follower @@ -27,9 +28,14 @@ def followed_by?(user) ThreadFollower.where(comment_thread: self, user: user).any? end + def second_follower + ThreadFollower.where(comment_thread: self).second + end + def can_access?(user) (!deleted? || user&.privilege?('flag_curate') || user&.has_post_privilege?('flag_curate', post)) && - post.can_access?(user) + (!post || post.can_access?(user)) && + (!is_private || followed_by?(user) || user&.is_moderator) end def self.post_followed?(post, user) @@ -42,7 +48,7 @@ def self.post_followed?(post, user) # automatically followed on new answer comment threads. Comment author follower creation is done # on the Comment model. def create_follower - if post.user.preference('auto_follow_comment_threads') == 'true' + if post.present? && post.user.preference('auto_follow_comment_threads') == 'true' ThreadFollower.create comment_thread: self, user: post.user end end diff --git a/app/models/mod_warning.rb b/app/models/mod_warning.rb index 67430b260..80baad47f 100644 --- a/app/models/mod_warning.rb +++ b/app/models/mod_warning.rb @@ -2,13 +2,20 @@ class ModWarning < ApplicationRecord # Warning class name not accepted by Rails, hence this needed self.table_name = 'warnings' - belongs_to :community_user + scope :global, -> { where(is_suspension: true, is_global: true) } + + belongs_to :community_user, optional: true + belongs_to :user, optional: true belongs_to :author, class_name: 'User' def suspension_active? active && is_suspension && !suspension_end.past? end + def global_suspension? + is_suspension && is_global + end + def body_as_html ApplicationController.helpers.render_markdown(body) end diff --git a/app/models/user.rb b/app/models/user.rb index 04e1219f7..70c60af42 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -80,6 +80,16 @@ def has_post_privilege?(name, post) end end + def globally_suspended? + return true if is_globally_suspended && !global_suspension_end.past? + + if is_globally_suspended + update(is_globally_suspended: false, global_suspension_end: nil) + end + + false + end + # Checks if the user can push a given post type to network # @param post_type [PostType] type of the post to be pushed # @return [Boolean] diff --git a/app/views/admin/impersonate.html.erb b/app/views/admin/impersonate.html.erb index beede0203..26aa18e54 100644 --- a/app/views/admin/impersonate.html.erb +++ b/app/views/admin/impersonate.html.erb @@ -1,31 +1,41 @@ -

Impersonate <%= @user.username %>

-

- As a developer, you have access to impersonate users to help in reproducing bug reports, among other things. -

+<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> -
-

Caution

-

- Using this tool may give you access to a user's personally identifiable information (PII). You are reminded of your - obligations under data protection laws to protect this information and not to use or disclose it without permission - or reasonable justification. This impersonation will be logged. -

-
+<%= render 'users/tabs', user: @user %> -
-
- <%= render 'users/common_card', user: @user, ckb: false %> -
-
+
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Impersonate <%= @user.username %>

+

+ As a developer, you have access to impersonate users to help in reproducing bug reports, among other things. +

-<%= form_tag impersonate_path(@user) do %> -
-
- <%= label_tag :comment, 'Why are you impersonating this user?', class: 'form-element' %> - <%= text_field_tag :comment, nil, class: 'form-element', required: true %> +
+

Caution

+

+ Using this tool may give you access to a user's personally identifiable information (PII). You are reminded of your + obligations under data protection laws to protect this information and not to use or disclose it without permission + or reasonable justification. This impersonation will be logged. +

-
- <%= submit_tag 'Impersonate', class: 'button is-danger h-m-b-2' %> + +
+
+ <%= render 'users/common_card', user: @user, ckb: false %> +
+ + <%= form_tag impersonate_path(@user) do %> +
+
+ <%= label_tag :comment, 'Why are you impersonating this user?', class: 'form-element' %> + <%= text_field_tag :comment, nil, class: 'form-element', required: true %> +
+
+ <%= submit_tag 'Impersonate', class: 'button is-danger h-m-b-2' %> +
+
+ <% end %>
-<% end %> \ No newline at end of file +
\ No newline at end of file diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 4d8e4f2c2..82d4dbe8a 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -38,7 +38,17 @@
<% end %>
- <%= user_link comment.user %> wrote + <% if comment.comment_thread.is_private %> + <% if comment.user == current_user || current_user.is_moderator || !comment.user.is_moderator %> + <%= user_link comment.user %> + <% if comment.user.is_moderator %>(only moderators can see name)<% end %> + <% else %> + moderator team + <% end %> + <% else %> + <%= user_link comment.user %> + <% end %> + wrote <%= time_ago_in_words(comment.created_at) %> ago <% if comment.updated_at > comment.created_at %> ยท edited <%= time_ago_in_words(comment.updated_at) %> ago @@ -48,10 +58,10 @@ <%= link_to comment_link(comment), class: 'js-comment-permalink', role: 'button' do %> copy link <% end %> - <% if with_post_link %> + <% if with_post_link && comment.post.present? %> <%= link_to 'post', generic_share_link(comment.post) %> <% end %> - <% if user_signed_in? && (comment.user == current_user || current_user.is_moderator) && params[:inline] != 'true' %> + <% if user_signed_in? && !comment.comment_thread.is_private && (comment.user == current_user || current_user.is_moderator) && params[:inline] != 'true' %> edit <% if comment.deleted %> undelete diff --git a/app/views/comments/thread.html.erb b/app/views/comments/thread.html.erb index 98aa32ebd..c62b39908 100644 --- a/app/views/comments/thread.html.erb +++ b/app/views/comments/thread.html.erb @@ -5,28 +5,33 @@ <% pingable = get_pingable(@comment_thread) %> -

Comments on - <%= @post.title.blank? && @post.parent.present? ? @post.parent.title : @post.title %> -

+<% if @post.present? %> +

Comments on + <%= @post.title.blank? && @post.parent.present? ? @post.parent.title : @post.title %> +

+ + <% if @post.parent.present? %> +
+ Parent + <%= render 'posts/expanded', post: @post.parent %> +
+ <% end %> -<% if @post.parent.present? %>
- Parent - <%= render 'posts/expanded', post: @post.parent %> + Post + <%= render 'posts/expanded', post: @post %>
-<% end %> -
- Post - <%= render 'posts/expanded', post: @post %> -
+<% else %> +

Private Notification for user <%= user_link @comment_thread.second_follower.user %>

+<% end %>
+ data-comments="<%= @comment_thread.reply_count %>" data-post="<%= @post&.id %>">
<% if params[:inline] == 'true' %> @@ -42,7 +47,7 @@ tools <% end %> - <% unless current_user.nil? %> + <% unless current_user.nil? || @comment_thread.is_private %> <% if @comment_thread.followed_by? current_user %> <% elsif flag.post_type == 'Comment' %> <%= render 'comments/comment', comment: flag.post, with_post_link: true %> + <% elsif flag.post_type == 'User' %> +
+ <%= render 'users/common_card', user: flag.post, ckb: false, external_url: true %> +
<% end %>

diff --git a/app/views/mod_warning/current.html.erb b/app/views/mod_warning/current.html.erb index f5f7eb3e9..156f08162 100644 --- a/app/views/mod_warning/current.html.erb +++ b/app/views/mod_warning/current.html.erb @@ -4,7 +4,47 @@ from the <%= SiteSetting['SiteName'] %> community moderation team:

-<% if @warning.is_suspension %> +<% if @warning.is_global %> + <% if @warning.suspension_active? %> +
+ <% else %> +
+ <%= raw(sanitize(@warning.body_as_html, scrubber: scrubber)) %> +

Your account was temporarily suspended, but the suspension period is now over. We look forward to your return and continued contributions to the site. In the event of continued rule violations after this period, however, your account may be suspended for longer periods. If you have any questions regarding the site rules, you can ask them in the Meta category of this site or on meta.codidact.com.

+ + <%= form_with url: current_mod_warning_approve_path, method: 'post' do %> + + + <%= submit_tag 'Continue', class: 'button is-filled' %> + <% if devise_sign_in_enabled? %> + <%= link_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% else %> + <%= link_to 'Sign Out', destroy_saml_user_session_path, method: :delete, class: 'button is-danger is-outlined' %> + <% end %> + <% end %> +
+ <% end %> +<% elsif @warning.is_suspension %> <% if @warning.suspension_active? %>
<%= raw(sanitize(@warning.body_as_html, scrubber: scrubber)) %> diff --git a/app/views/mod_warning/log.html.erb b/app/views/mod_warning/log.html.erb index c3493e706..2eb6590ec 100644 --- a/app/views/mod_warning/log.html.erb +++ b/app/views/mod_warning/log.html.erb @@ -1,46 +1,72 @@ -

Warnings sent to <%= user_link @user %>

+<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> - - - - - - - - - <% @warnings.each do |w| %> - - - - - - - +<%= render 'users/tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Previously Sent Messages and Warnings

+ + <% if @entries.size == 0 %> +

No messages or warnings found for this user.

+ <% end %> + + <% @entries.each do |e| %> +
+ <% if e[:type] == :message %> + <% m = e[:value] %> +
+
+ Message +
+
<%= time_ago_in_words(m.created_at) %> ago by <%= user_link m.comments.first.user %>
+
+
+ <%= raw(sanitize(render_markdown(m.comments.first.content), scrubber: scrubber)) %> +
+ + <% elsif e[:type] == :warning %> + <% w = e[:value] %> +
+
+ <% if w.suspension_active? %> + <%= form_tag lift_mod_warning_url(@user.id), method: :post, class: 'mod-warnings-clear-form' do %> + <%= submit_tag '(lift)', class: 'link is-red' %> + <% end %> · + Current + <% elsif w.active %> + Unread + <% elsif w.read %> + Read + <% else %> + Lifted + <% end %> +
+
+ <% if w.is_suspension %> + <% diff = ((w.suspension_end - w.created_at) / (3600 * 24)).to_i %> + Suspension + length: <%= diff %>d + <% if w.is_global %> + network-wide + <% end %> + <% else %> + Warning + <% end %> +
+
<%= time_ago_in_words(w.created_at) %> ago by <%= user_link w.author %>
+
+
+ <%= raw(sanitize(render_markdown(w.body), scrubber: scrubber)) %> +
+ <% end %> +
<% end %> -
DateTypeFromExcerptStatus
- <%= time_ago_in_words(w.created_at) %> ago - - <% if w.is_suspension %> - <% diff = ((w.suspension_end - w.created_at) / (3600 * 24)).to_i %> - Suspension (<%= diff %>d) - <% else %> - Warning - <% end %> - - <%= user_link w.author %> - - <%= raw(sanitize(render_markdown(w.body), scrubber: scrubber)) %> - - <% if w.suspension_active? %> - Current - <%= form_tag lift_mod_warning_url(@user.id), method: :post do %> - <%= submit_tag '(lift)', class: 'link is-red' %> - <% end %> - <% elsif w.active %> - Unread - <% elsif w.read %> - Read - <% else %> - Lifted - <% end %> -
\ No newline at end of file +
+
\ No newline at end of file diff --git a/app/views/mod_warning/new.html.erb b/app/views/mod_warning/new.html.erb index 8c23bc32f..9566d01cd 100644 --- a/app/views/mod_warning/new.html.erb +++ b/app/views/mod_warning/new.html.erb @@ -1,79 +1,94 @@ -<% content_for :head do %> - <%= render 'posts/markdown_script' %> -<% end %> - -

Warn or suspend <%= user_link @user %>

- -
-

Use the warning tool only against users who have violated the site rules. Prefer other measurements, such as friendly asking the user to stop certain behaviors in a comment.

-
- -<%= form_for @warning, url: create_mod_warning_path(@user.id), method: :post do |f| %> -
-
- 1. Choose a template -
-
-
-

Choose a template, which explains, why you are contacting the user. If none is applicable, choose to send a custom message.

- - -
-
-
- 2. Review the message -
-
-
-

Review the generated message and add details. Do not add salutations or information about possible suspensions, as they are generated automatically.

- -
- <%= render 'shared/body_field', f: f, field_name: :body, field_label: 'Body' %> -
-
-
-
-
- 3. Choose optional suspension -
-
-
-

Decide, whether or not to suspend the user, and if, for how long. Choose an optional message shown publicly on the user profile.

- - <% if @prior_warning_count == 0 %> -

Info: This user has no prior warnings. The system recommends issuing only a warning, unless the user is destructive and needs to be stopped immediately.

- <% elsif @prior_warning_count >= 5 %> -

Info: This user has <%= @prior_warning_count %> prior warnings. The system recommends suspending them for 365 days (the maximum).

- <% else %> - <% lengths = { 1 => 3, 2 => 7, 3 => 30, 4 => 180 } %> -

Info: This user has <%= @prior_warning_count %> prior warnings. The system recommends suspending them for <%= lengths[@prior_warning_count] %> days.

- <% end %> - -
- <%= f.label :is_suspension, 'Suspend this user account?', class: 'form-element' %> - - -
- -
- <%= f.label :suspension_duration, 'If suspending, for how long?', class: 'form-element' %> -
Enter the number of days. At least 1, at most 365.
- <%= f.number_field :suspension_duration, in: 1..365, class: 'form-element' %> -
- -
- <%= f.label :suspension_public_notice, 'If suspending, what public notice, if any, do you want to show?', class: 'form-element' %> - <%= f.select :suspension_public_notice, options_for_select([['for rule violations', 'for rule violations'], ['to cool down', 'to cool down']]), { include_blank: true }, class: 'form-element' %> -
-
-
- -
-<% end %> +<% content_for :head do %> + <%= render 'posts/markdown_script' %> +<% end %> + +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'users/tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Warn or Suspend User

+ +
+

Use the warning tool only against users who have violated the site rules. Prefer other measures where possible, such as a public + comment or <%= link_to 'contacting them privately', mod_contact_path(@user) %>.

+
+ + <%= form_for @warning, url: create_mod_warning_path(@user.id), method: :post do |f| %> +
+
+ 1. Choose a template +
+
+
+

Choose a template that explains why you are contacting the user. If none are applicable, choose to send a + custom message.

+ + +
+
+
+ 2. Review the message +
+
+
+

Review the generated message and add details. Do not add salutations or information about possible + suspensions, as they are generated automatically.

+ +
+ <%= render 'shared/body_field', f: f, field_name: :body, field_label: 'Body' %> +
+
+
+
+
+ 3. Choose optional suspension +
+
+
+

Decide whether or not to suspend the user, and if so for how long. Choose an optional message shown + publicly on the user profile.

+ + <% if @prior_warning_count == 0 %> +

Info: This user has no prior warnings. The system recommends issuing only a warning, unless the user is destructive and needs to be stopped immediately.

+ <% elsif @prior_warning_count >= 5 %> +

Info: This user has <%= @prior_warning_count %> prior warnings. The system recommends suspending them for 365 days (the maximum).

+ <% else %> + <% lengths = { 1 => 3, 2 => 7, 3 => 30, 4 => 180 } %> +

Info: This user has <%= @prior_warning_count %> prior warnings. The system recommends suspending them for <%= lengths[@prior_warning_count] %> days.

+ <% end %> + +
+ <%= f.label :is_suspension, 'Suspend this user account?', class: 'form-element' %> + + +
+ +
+ <%= f.label :suspension_duration, 'If suspending, for how long?', class: 'form-element' %> +
Enter the number of days. At least 1, at most 365.
+ <%= f.number_field :suspension_duration, in: 1..365, class: 'form-element' %> +
+ +
+ <%= f.label :suspension_public_notice, 'If suspending, what public notice, if any, do you want to show?', class: 'form-element' %> + <%= f.select :suspension_public_notice, options_for_select([['for rule violations', 'for rule violations'], ['to cool down', 'to cool down']]), { include_blank: true }, class: 'form-element' %> +
+
+
+ +
+ <% end %> + +
+
\ No newline at end of file diff --git a/app/views/mod_warning/new_global.html.erb b/app/views/mod_warning/new_global.html.erb new file mode 100644 index 000000000..5c8a6de1f --- /dev/null +++ b/app/views/mod_warning/new_global.html.erb @@ -0,0 +1,42 @@ +<% content_for :head do %> + <%= render 'posts/markdown_script' %> +<% end %> + +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'users/tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Network-wide Suspension Tool

+ +
+

As a global moderator, you may suspend users network-wide. Using this tool applies a suspension on all community profiles of this user.

+

Only use this tool if no other mean of communication or correction (including warnings and per-community suspensions) have been effective or if such a network-wide suspension is immediately necessary to prevent grave harm that would occur otherwise. Prior consultation with your fellow community team is strongly recommended.

+
+ + <%= form_for @warning, url: create_global_warning_path(@user.id), method: :post do |f| %> + +

Message

+ +

This suspension message will be shown to the user when they try accessing any page.

+

Moderators of all communities might see this message in the warnings and suspensions list of their community, so be careful which information you share here.

+
+ <%= render 'shared/body_field', f: f, field_name: :body, field_label: 'Message' %> +
+
+ +

Suspension Details

+
+ <%= f.label :suspension_duration, 'Duration', class: 'form-element' %> +
Enter the number of days. At least 1, at most 365.
+ <%= f.number_field :suspension_duration, in: 1..365, class: 'form-element' %> +
+ + <%= f.submit 'Suspend user globally', class: 'button is-danger is-filled' %> + <% end %> + +
+
\ No newline at end of file diff --git a/app/views/moderator/user_vote_summary.html.erb b/app/views/moderator/user_vote_summary.html.erb index 65fb666a3..3c17e6783 100644 --- a/app/views/moderator/user_vote_summary.html.erb +++ b/app/views/moderator/user_vote_summary.html.erb @@ -1,53 +1,62 @@ -

Vote Summary: <%= user_link @user %>>

-

- This is a summary of votes cast and received by this user. This may help you to identify voting patterns and - sock puppets, but use caution: what you see as a pattern may also be coincidence. Look for conclusive undeniable - patterns before using this data for sanctions. -

+<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> -

- Key: - <%= text_bg 'yellow-200', '> 20%', class: 'has-padding-1 has-margin-horizontal-1' %> - <%= text_bg 'yellow-700', '> 30%', class: 'has-padding-1 has-margin-horizontal-1' %> - <%= text_bg 'red-200', '> 40%', class: 'has-padding-1 has-margin-horizontal-1' %> - <%= text_bg 'red-700', '> 50%', class: 'has-color-white has-padding-1 has-margin-horizontal-1' %> -

+<%= render 'users/tabs', user: @user %> -<% [:cast, :received].each do |type| %> -

Votes <%= type %>

+
+ <%= render 'shared/user_mod_sidebar', user: @user %> - - - - - - - - - - - <% @vote_data[type].breakdown.each do |key, count| %> - - - - - <% pct = count * 100.0 / @vote_data[type].total %> - - - <% end %> - -
<%= type == :cast ? 'To' : 'From' %> userVote typeVote count% of total
<%= user_link @users.select { |x| x.id == key[0] }[0] %><%= key[1] %><%= count %> - <% if pct >= 50 %> - <%= text_bg 'red-700', number_to_percentage(pct, precision: 2), class: 'has-color-white has-padding-1' %> - <% elsif pct >= 40 %> - <%= text_bg 'red-200', number_to_percentage(pct, precision: 2), class: 'has-padding-1' %> - <% elsif pct >= 30 %> - <%= text_bg 'yellow-700', number_to_percentage(pct, precision: 2), class: 'has-padding-1' %> - <% elsif pct >= 20 %> - <%= text_bg 'yellow-200', number_to_percentage(pct, precision: 2), class: 'has-padding-1' %> - <% else %> - <%= number_to_percentage(pct, precision: 2) %> - <% end %> -
-<% end %> +
+

Vote Summary

+

+ This is a summary of votes cast and received by this user. This may help you to identify voting patterns and + sock puppets, but use caution: what you see as a pattern may also be coincidence. Look for conclusive undeniable + patterns before using this data for sanctions. +

+ +

+ Key: + <%= text_bg 'yellow-200', '> 20%', class: 'has-padding-1 has-margin-horizontal-1' %> + <%= text_bg 'yellow-700', '> 30%', class: 'has-padding-1 has-margin-horizontal-1' %> + <%= text_bg 'red-200', '> 40%', class: 'has-padding-1 has-margin-horizontal-1' %> + <%= text_bg 'red-700', '> 50%', class: 'has-color-white has-padding-1 has-margin-horizontal-1' %> +

+ <% [:cast, :received].each do |type| %> +

Votes <%= type %>

+ + + + + + + + + + + + <% @vote_data[type].breakdown.each do |key, count| %> + + + + + <% pct = count * 100.0 / @vote_data[type].total %> + + + <% end %> + +
<%= type == :cast ? 'To' : 'From' %> userVote typeVote count% of total
<%= user_link @users.select { |x| x.id == key[0] }[0] %><%= key[1] %><%= count %> + <% if pct >= 50 %> + <%= text_bg 'red-700', number_to_percentage(pct, precision: 2), class: 'has-color-white has-padding-1' %> + <% elsif pct >= 40 %> + <%= text_bg 'red-200', number_to_percentage(pct, precision: 2), class: 'has-padding-1' %> + <% elsif pct >= 30 %> + <%= text_bg 'yellow-700', number_to_percentage(pct, precision: 2), class: 'has-padding-1' %> + <% elsif pct >= 20 %> + <%= text_bg 'yellow-200', number_to_percentage(pct, precision: 2), class: 'has-padding-1' %> + <% else %> + <%= number_to_percentage(pct, precision: 2) %> + <% end %> +
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/shared/_user_mod_sidebar.html.erb b/app/views/shared/_user_mod_sidebar.html.erb new file mode 100644 index 000000000..e935561d3 --- /dev/null +++ b/app/views/shared/_user_mod_sidebar.html.erb @@ -0,0 +1,116 @@ +
+
+
+ User Moderation Tools +
+
+
+ <%= render 'users/common_card', user: user, ckb: false %> +
+
+
+ +
+
+

You can always ask the Community Team for assistance:

+ <%= link_to user_escalation_path(user), + class: "button is-primary is-outlined #{current_page?(user_escalation_path(user)) ? 'is-active' : ''}" do %> + + Contact Community Team + <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/users/_common_card.html.erb b/app/views/users/_common_card.html.erb index 3df09729f..a5a99a336 100644 --- a/app/views/users/_common_card.html.erb +++ b/app/views/users/_common_card.html.erb @@ -8,6 +8,7 @@ <% ckb ||= false %> <% small ||= false %> +<% external_url ||= false%> <% if user.nil? || deleted_user?(user) %>
@@ -29,7 +30,7 @@ data = {'ckb-item-link': ''} end %> - <%= link_to user_path(user), dir: 'ltr', class: small ? :'user-card--link-small' :'user-card--link', data: data do %> + <%= link_to external_url ? user_url(user) : user_path(user), dir: 'ltr', class: small ? :'user-card--link-small' :'user-card--link', data: data do %> <%= rtl_safe_username(user) %> <% if user.is_admin && SiteSetting['AdminBadgeCharacter'] %> diff --git a/app/views/users/_tabs.html.erb b/app/views/users/_tabs.html.erb index fb2c9d191..89bc6fdba 100644 --- a/app/views/users/_tabs.html.erb +++ b/app/views/users/_tabs.html.erb @@ -28,4 +28,26 @@ <%= link_to network_path(user), class: "tabs--item #{current_page?(network_path(user)) ? 'is-active' : ''}" do %> All Communities <% end %> +
+ <% if current_user&.is_moderator %> + <%= link_to mod_user_path(user), class: "tabs--item #{( + current_page?(mod_user_path(user)) || + current_page?(full_user_log_path(user)) || + current_page?(user_annotations_path(user)) || + current_page?(mod_vote_summary_path(user)) || + current_page?(user_privileges_path(user)) || + current_page?(mod_contact_path(user)) || + current_page?(mod_warning_log_path(user)) || + current_page?(new_mod_warning_path(user)) || + current_page?(mod_reset_profile_path(user)) || + current_page?(mod_delete_path(user)) || + current_page?(global_user_log_path(user)) || + current_page?(new_global_warning_path(user)) || + current_page?(mod_delete_network_account_path(user)) || + current_page?(mod_failban_path(user)) || + current_page?(start_impersonating_path(user)) + ) ? 'is-active' : ''}" do %> + Moderator Tools <% if @user.community_user.mod_warnings&.size.positive? %> (<%= pluralize(@user.community_user.mod_warnings.count, 'message') %>) <% end %> + <% end %> + <% end %>
diff --git a/app/views/users/annotations.html.erb b/app/views/users/annotations.html.erb index 0c0c4ec20..4fef6ce37 100644 --- a/app/views/users/annotations.html.erb +++ b/app/views/users/annotations.html.erb @@ -1,29 +1,39 @@ -

User annotations

+<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> -
- Add an annotation - <% if defined?(@log) && @log.errors.any? %> -
- There was an error while trying to save your annotation. -
    - <% @log.errors.full_messages.each do |msg| %> -
  • <%= msg %>
  • - <% end %> -
-
- <% end %> - <%= form_tag annotate_user_path(@user), method: :post do %> -
- <%= label_tag :comment, 'Comment', class: 'form-element' %> - <%= text_field_tag :comment, params[:comment], class: 'form-element' %> -
+<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> - <%= submit_tag 'Save', class: 'button is-filled' %> - <% end %> -
+
+

User annotations

-
-

<%= pluralize(@logs.count, 'log') %>

-
+
+ Add an annotation + <% if defined?(@log) && @log.errors.any? %> +
+ There was an error while trying to save your annotation. +
    + <% @log.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + <%= form_tag annotate_user_path(@user), method: :post do %> +
+ <%= label_tag :comment, 'Comment', class: 'form-element' %> + <%= text_field_tag :comment, params[:comment], class: 'form-element' %> +
+ + <%= submit_tag 'Save', class: 'button is-filled' %> + <% end %> +
+ +
+

<%= pluralize(@logs.count, 'log') %>

+
-<%= render 'admin/log_table' %> \ No newline at end of file + <%= render 'admin/log_table' %> +
+
\ No newline at end of file diff --git a/app/views/users/full_log.html.erb b/app/views/users/full_log.html.erb index bbd01e835..dcb10556d 100644 --- a/app/views/users/full_log.html.erb +++ b/app/views/users/full_log.html.erb @@ -1,44 +1,164 @@ <% content_for :title, "Full Activity Log: #{rtl_safe_username(@user)}" %> -

Full activity log for <%= user_link @user %>

- -

This is a filterable log for all activity by the user. You can consult it for moderation decisions. Do not share this information to people, who do not have access to it.

- -<% if params[:filter] == 'interesting' %> -

You are looking at negative interactions the user had with this site. These are not necessarily bad, just actions at which you should look more closely. This list includes deleted comments, rejected flags and edit suggestions and negatively received posts.

-<% end %> - -
- - Show all events - - <% if @interesting > 0 %> - - Negative - <%= @interesting %> - +<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Full activity log

+ +

This is a filterable log for all activity by the user. You can consult it for moderation decisions. Do not share this information to people, who do not have access to it.

+ + <% if params[:filter] == 'interesting' %> +

You are looking at negative interactions the user had with this site. These are not necessarily bad, just actions at which you should look more closely. This list includes deleted comments, rejected flags and edit suggestions and negatively received posts.

<% end %> - - Posts - <% if @posts > 0 %><%= @posts %><% end %> - - - Comments - <% if @comments > 0 %><%= @comments %><% end %> - - - Flags - <% if @flags > 0 %><%= @flags %><% end %> - - - Edits - <% if @all_edits > 0 %><%= @all_edits %><% end %> - - - Warnings - <% if @mod_warnings_received > 0 %><%= @mod_warnings_received %><% end %> - -
- -<%= render 'activity_items', mod: true %> + + + + + + + + + + + <% @items.each do |i| %> + + <% if i.class == Post %> + + + + + <% elsif i.class == Comment %> + + + + + <% elsif i.class == PostHistory %> + + + + + <% elsif i.class == SuggestedEdit %> + + + + + <% elsif i.class == ModWarning %> + + + + + <% elsif i.class == Flag %> + + + + + <% else %> + + + + + <% end %> + + + <% end %> +
TypeOn...ExcerptStatusDate
+ + <%= (i.question? ? "Question" : (i.article? ? "Article" : "Answer")) %> + + + <% if !i.answer? %> + <%= i.title %>
+ <% else %> + A: <%= i.parent.title %>
+ <% end %> + <%= i.body_plain[0..300] + ((i.body_plain.length > 300) ? "..." : "") %>
+ <%= link_to '(more)', generic_share_link(i)%> +
+ Comment + + <% if i.post.nil? %> + <%= link_to 'Private', comment_link(i), 'aria-label': 'Location of comment in private thread' %> + <% else %> + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + <% end %> + + <%= i.content[0..300] + ((i.content.length > 300) ? "..." : "") %>
+ <%= link_to '(more)', comment_link(i), 'aria-label': 'More information about comment' %> +
+ Edit + + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + + <% if i.comment %> + <%= i.post_history_type.name.gsub("_", " ").capitalize %>:
<%= i.comment %> + <% else %> + <%= i.post_history_type.name.gsub("_", " ").capitalize %> + <% end %> +
+ Suggested Edit + + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + + Suggested edit:
<%= i.comment %>
<%= link_to '(more)', suggested_edit_url(i.id) %> +
+ <%= (i.pending? ? "pending" : (i.approved? ? "helpful" : "declined")) %> + + Warning + + <%= i.body[0..300] + ((i.body.length > 300) ? "..." : "") %> + + Flag + + <% if i.post_type == 'Post' %> + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + <% elsif i.post_type == 'Comment' %> + <%= link_to "Comment #" + i.post.id.to_s, comment_link(i.post.id)%> + <% elsif i.post_type == 'User' %> + <%= link_to "User #" + i.post.id.to_s, user_path(i.post)%> + <% end %> + + <%= i.reason[0..300] + ((i.reason.length > 300) ? "..." : "") %>
+
+ <%= i.status || "pending" %> + + Unknown + <%= i.class %> + <%= time_ago_in_words(i.created_at) %> ago +
+ +
+
\ No newline at end of file diff --git a/app/views/users/global_log.html.erb b/app/views/users/global_log.html.erb new file mode 100644 index 000000000..501a88f75 --- /dev/null +++ b/app/views/users/global_log.html.erb @@ -0,0 +1,176 @@ +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Global activity log

+ +

This is a filterable log for all activity by the user network-wide. You can consult it for moderation decisions. Do not share this information to people, who do not have access to it, which generally includes community moderators, unless relevant to a site they are a mod on

+ + <% if params[:filter] == 'interesting' %> +

You are looking at negative interactions the user had with this site. These are not necessarily bad, just actions at which you should look more closely. This list includes deleted comments, rejected flags and edit suggestions and negatively received posts.

+ <% end %> + + + + + + + + + + + + + <% @items.each do |i| %> + + <% if i.class == Post %> + + + + + + <% elsif i.class == Comment %> + + + + + + <% elsif i.class == PostHistory %> + + + + + + <% elsif i.class == SuggestedEdit %> + + + + + + <% elsif i.class == ModWarning %> + + <% if i.community_user.present? %> + + <% else %> + + <% end %> + + + + <% elsif i.class == Flag %> + + + + + + <% else %> + + + + + + <% end %> + + + <% end %> +
TypeCommunityOn...ExcerptStatusDate
+ + <%= (i.question? ? "Question" : (i.article? ? "Article" : "Answer")) %> + + <%= link_to i.community.name, "//#{i.community.host}" %> + <% if !i.answer? %> + <%= i.title %>
+ <% else %> + A: <%= i.parent.title %>
+ <% end %> + <%= i.body_plain[0..300] + ((i.body_plain.length > 300) ? "..." : "") %>
+ <%= link_to '(more)', generic_share_link(i)%> +
+ Comment + <%= link_to i.community.name, "//#{i.community.host}" %> + <% if i.post.nil? %> + <%= link_to 'Private', comment_link(i), 'aria-label': 'Location of comment in private thread' %> + <% else %> + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + <% end %> + + <%= i.content[0..300] + ((i.content.length > 300) ? "..." : "") %>
+ <%= link_to '(more)', comment_link(i), 'aria-label': 'More information about comment' %> +
+ Edit + <%= link_to i.community.name, "//#{i.community.host}" %> + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + + <% if i.comment %> + <%= i.post_history_type.name.gsub("_", " ").capitalize %>:
<%= i.comment %> + <% else %> + <%= i.post_history_type.name.gsub("_", " ").capitalize %> + <% end %> +
+ Suggested Edit + <%= link_to i.community.name, "//#{i.community.host}" %> + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + + Suggested edit:
<%= i.comment %>
<%= link_to '(more)', suggested_edit_url(i.id) %> +
+ <%= (i.pending? ? "pending" : (i.approved? ? "helpful" : "declined")) %> + + Warning + <%= link_to i.community_user.community.name, "//#{i.community_user.community.host}" %>Global + <%= i.body[0..300] + ((i.body.length > 300) ? "..." : "") %> + + Flag + <%= link_to i.community.name, "//#{i.community.host}" %> + <% if i.post_type == 'Post' %> + <%= link_to "Post #" + i.post.id.to_s, generic_share_link(i.post)%> + <% elsif i.post_type == 'Comment' %> + <%= link_to "Comment #" + i.post.id.to_s, comment_link(i.post.id)%> + <% elsif i.post_type == 'User' %> + <%= link_to "User #" + i.post.id.to_s, user_path(i.post)%> + <% end %> + + <%= i.reason[0..300] + ((i.reason.length > 300) ? "..." : "") %>
+
+ <%= i.status || "pending" %> + + Unknown + <%= link_to i.community.name, "//#{i.community.host}" %><%= i.class %> + <%= time_ago_in_words(i.created_at) %> ago +
+ +
+
\ No newline at end of file diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index 4f4cd8050..3edb57e37 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -1,41 +1,199 @@ <% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> -

Moderator Tools: <%= user_link @user %>

+<%= render 'tabs', user: @user %> -
-
Links
-
-
    -
  • full activity log
  • -
  • <%= link_to 'annotations on user', user_annotations_path(@user) %>
  • -
  • privileges
  • -
  • warnings and suspensions sent to user <% if @user.community_user.suspended? %>(includes lifting the suspension)<% end %>
  • -
  • warn or suspend user
  • -
  • <%= link_to 'vote summary', mod_vote_summary_path(@user) %>
  • - <% if current_user.developer %> -
  • <%= link_to 'impersonate', start_impersonating_path(@user), class: 'is-yellow' %>
  • - <% end %> -
-
-
+
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Dashboard

+

Please note that information shown in these user moderation tools is sensitive and should not be shared with anyone outside the moderator and admin team.

+ +
+
Account Information
+
+ + + + + + + + + + + + + +
User Name<%= rtl_safe_username(@user) %>
Account ID#<%= @user.id %>
Joined<%= @user.created_at.strftime("%Y-%m-%d") %> (network), <%= @user.community_user.created_at.strftime("%Y-%m-%d") %> (community)
+
+
+ +
+
Activity Summary
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BehaviorSince CreationLast YearLast MonthMost Recent
Posts Written + <%= @user.posts.count %> + + <%= @user.posts.where(created_at: 360.days.ago..DateTime.now).count %> + + <%= @user.posts.where(created_at: 30.days.ago..DateTime.now).count %> + + <% last_post = @user.posts.last %> + <% if last_post %> + <%= time_ago_in_words(last_post.created_at, locale: :en_abbrev) %> ago + <% else %> + never + <% end %> +
Votes Cast + <%= @user.votes.count %> + + <%= @user.votes.where(created_at: 360.days.ago..DateTime.now).count %> + + <%= @user.votes.where(created_at: 30.days.ago..DateTime.now).count %> + + <% last_vote = @user.votes.last %> + <% if last_vote %> + <%= time_ago_in_words(last_vote.created_at, locale: :en_abbrev) %> ago + <% else %> + never + <% end %> +
Comments written + <%= @user.comments.count %> + + <%= @user.comments.where(created_at: 360.days.ago..DateTime.now).count %> + + <%= @user.comments.where(created_at: 30.days.ago..DateTime.now).count %> + + <% last_comment = @user.comments.last %> + <% if last_comment %> + <%= time_ago_in_words(last_comment.created_at, locale: :en_abbrev) %> ago + <% else %> + never + <% end %> +
Edits Suggested + <%= @user.suggested_edits.count %> + + <%= @user.suggested_edits.where(created_at: 360.days.ago..DateTime.now).count %> + + <%= @user.suggested_edits.where(created_at: 30.days.ago..DateTime.now).count %> + + <% last_suggested_edit = @user.suggested_edits.last %> + <% if last_suggested_edit %> + <%= time_ago_in_words(last_suggested_edit.created_at, locale: :en_abbrev) %> ago + <% else %> + never + <% end %> +
Flags Raised + <%= @user.flags.count %> + + <%= @user.flags.where(created_at: 360.days.ago..DateTime.now).count %> + + <%= @user.votes.where(created_at: 30.days.ago..DateTime.now).count %> + + <% last_flag = @user.flags.last %> + <% if last_flag %> + <%= time_ago_in_words(last_flag.created_at, locale: :en_abbrev) %> ago + <% else %> + never + <% end %> +
+
+
-
-
Danger Zone
-
-

Take care! Actions in this section may not be reversible, and you will not be asked to confirm - after initiating an action.

-
- <%= link_to 'Destroy user', destroy_user_path(@user.id), remote: true, - method: :delete, class: 'js-destroy-user button is-danger is-filled', role: 'button' %> - <%= link_to 'Delete community profile', soft_delete_user_path(@user.id, type: 'profile'), remote: true, - method: :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> - <% if current_user.is_global_moderator || current_user.is_global_admin %> - <%= link_to 'Delete user network-wide', soft_delete_user_path(@user.id, type: 'user'), remote: true, - method: :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> - <% end %> - <% if current_user.is_global_admin %> - <%= link_to 'Feed to STAT (180 days)', hellban_user_path(@user), method: :post, class: 'button is-danger is-filled', role: 'button' %> - <% end %> +
+
Moderation Summary
+
+ <% annotations_count = AuditLog.where(log_type: 'user_annotation', related: @user).count %> + <% warnings_count = ModWarning.where(community_user: @user.community_user, is_suspension: false).count %> + <% suspensions_count = ModWarning.where(community_user: @user.community_user, is_suspension: true).count %> + <% suspensions_count += ModWarning.where(user: @user, is_global: true, is_suspension: true).count %> + + + + + + + + + + + + + + + + + +
Annotations + <% if annotations_count > 0 %> + <%= annotations_count %> + <% else %> + 0 + <% end %> +
Currently Suspended? + <% if @user.globally_suspended? || @user.community_user.suspended? %> + yes + <% else %> + 0 + <% end %> +
Warnings + <% if warnings_count > 0 %> + <%= warnings_count %> + <% else %> + 0 + <% end %> +
Suspensions + <% if suspensions_count > 0 %> + <%= suspensions_count %> + <% else %> + 0 + <% end %> +
+
diff --git a/app/views/users/mod_contact.html.erb b/app/views/users/mod_contact.html.erb new file mode 100644 index 000000000..c386d9bc8 --- /dev/null +++ b/app/views/users/mod_contact.html.erb @@ -0,0 +1,47 @@ +<% content_for :head do %> + <%= render 'posts/markdown_script' %> +<% end %> + +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'users/tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Contact Privately

+ +
+

As a moderator, you may contact this user privately.

+ +

This message will only be visible to the contacted user and to the moderation team. + Your username will be hidden for the contacted user; we generally recommend not identifying yourself.

+ +

If means of public and private contact have not been effective, + a <%= link_to 'formal warning', new_mod_warning_path(@user) %> might be in order.

+
+ + <%= form_tag mod_message_path do %> + <%= label_tag :title, 'Subject', class: 'form-element' %> + <%= text_field_tag :title, '', class: 'form-element', data: { character_count: ".js-character-count-thread-title" } %> + + + + 0 / 255 + + + <%= label_tag :body, 'Message', class: 'form-element' %> + <%= text_area_tag :body, '', class: 'form-element is-large js-comment-field', required: true, + data: { thread: '-1', character_count: ".js-character-count-modmsg-body" } %> + + + 0 / 1000 + + + <%= submit_tag 'Create thread', class: 'button is-filled', id: "create_modmsg_button", disabled: true %> + <% end %> +
+
\ No newline at end of file diff --git a/app/views/users/mod_delete.html.erb b/app/views/users/mod_delete.html.erb new file mode 100644 index 000000000..518f10a0b --- /dev/null +++ b/app/views/users/mod_delete.html.erb @@ -0,0 +1,27 @@ +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Delete Account

+ +

Some users are just blatant spammers or trolls and some users are just unwilling to follow site rules, even after repeated warnings and suspensions. As a moderator, you may delete the user account in these cases.

+ +
+

Take care! These actions may not be reversible and you will not be asked to confirm after initiating an action.

+
+ +
+
+

Delete Community Profile

+

Delete the community profile of users who are unwilling to follow the rules of this site, even after repeated warnings and suspensions. Choose this option if a user has requested deletion of their profile on one site, once you have confirmed their identity and request.

+ + <%= link_to 'Delete community profile', soft_delete_user_path(@user.id, type: 'profile'), remote: true, + method: :delete, class: 'js-soft-delete button is-danger is-filled' %> +
+
+
+
diff --git a/app/views/users/mod_delete_network_account.html.erb b/app/views/users/mod_delete_network_account.html.erb new file mode 100644 index 000000000..6df3c2409 --- /dev/null +++ b/app/views/users/mod_delete_network_account.html.erb @@ -0,0 +1,27 @@ +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Network-wide Account Deletion

+ +

As a global moderator, you may delete the user account network-wide.

+ +
+

Take care! These actions may not be reversible and you will not be asked to confirm after initiating an action.

+
+ +
+
+

Delete User Network-wide

+

Delete the account network-wide for users who are unwilling to follow the rules of this network, even after repeated warnings and suspensions. Choose this option if a user has requested deletion of their profile on all sites, once you have confirmed their identity and request.

+ + <%= link_to 'Delete user network-wide', soft_delete_user_path(@user.id, type: 'user'), remote: true, + method: :delete, class: 'js-soft-delete button is-danger is-filled' %> +
+
+
+
diff --git a/app/views/users/mod_escalation.html.erb b/app/views/users/mod_escalation.html.erb new file mode 100644 index 000000000..68073492f --- /dev/null +++ b/app/views/users/mod_escalation.html.erb @@ -0,0 +1,32 @@ +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Contact Community Team

+

You can always ask the Community Team for assistance.

+ +

Use this tool, for example,

+ +
    +
  • if you are suspecting voting fraud and require confirmation by someone with database access,
  • +
  • if you are suspecting that a user is misbehaving across multiple communities, or
  • +
  • if there is anything else you feel uncertain with or want assistance by the Community Team.
  • +
+ +

If in doubt, feel free to use this tool. You can also contact us at <%= SiteSetting['AdminContactEmail'] %> or reach out to us in chat.

+ + <%= form_for @flag, url: user_escalate_path(@user), method: :post do |f| %> +
+ <%= f.label :reason, 'Request', class: 'form-element' %> +
Explain, what you need assistance with.
+ <%= f.text_area :reason, class: 'form-element' %> +
+ <%= f.submit 'Escalate to Community Team', class: 'button is-filled' %> + <% end %> + +
+
diff --git a/app/views/users/mod_failban.html.erb b/app/views/users/mod_failban.html.erb new file mode 100644 index 000000000..b7a2b95ce --- /dev/null +++ b/app/views/users/mod_failban.html.erb @@ -0,0 +1,26 @@ +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Fail-ban

+ +
+

Take care! These actions may not be reversible and you will not be asked to confirm after initiating an action.

+
+ +
+ <% if current_user.is_global_admin %> +
+

Feed to STAT

+

Blatant spammers and trolls may be fed into STAT ("Stop The Awful Troll") which causes the system to fail-ban them and limits the amount of damage they might possibly do. Only use for accounts where you are sure that they are never going to constructively use this site.

+ + <%= link_to 'Feed to STAT (180 days)', failban_user_path(@user), method: :post, class: 'button is-danger is-filled' %> +
+ <% end %> +
+
+
diff --git a/app/views/users/mod_privileges.html.erb b/app/views/users/mod_privileges.html.erb index 1765d6536..e8537d96b 100644 --- a/app/views/users/mod_privileges.html.erb +++ b/app/views/users/mod_privileges.html.erb @@ -1,176 +1,185 @@ -<% content_for :title, "Moderator Tools: #{@user.username}" %> +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> -

Privileges of <%= link_to @user.username, user_path(@user) %>

+<%= render 'tabs', user: @user %> -
-
- ability - page - Abilities -
- <% @abilities.each do |a| %> - <% next if a.internal_id == 'mod' %> - <% ua = @user.privilege a.internal_id %> -
-
-
- -
-
-

- <%= a.name %> -

-

<%= a.summary %>

- <% unless ua.nil? %> -

- Delete -

- <% end %> -
-
- <% if ua.nil? %> - - <% elsif ua.suspended? %> - - <% else %> - -
-
suspend ability to <%= a.name %>
- -
in days; leave blank for permanent
- +
+ <%= render 'shared/user_mod_sidebar', user: @user %> - -
will be privately shown to user
- +
+

User Privileges

- +
+
+ ability + page + Abilities +
+ <% @abilities.each do |a| %> + <% next if a.internal_id == 'mod' %> + <% ua = @user.privilege a.internal_id %> +
+
+
+ +
+
+

+ <%= a.name %> +

+

<%= a.summary %>

+ <% unless ua.nil? %> +

+ Delete +

+ <% end %>
- <% end %> +
+ <% if ua.nil? %> + + <% elsif ua.suspended? %> + + <% else %> + +
+
suspend ability to <%= a.name %>
+ +
in days; leave blank for permanent
+ + + +
will be privately shown to user
+ + + +
+ <% end %> +
+
+ <% end %> +
- <% end %> - -
-<% if current_user.is_admin %> -
-
- Roles -
-
-
-
- + <% if current_user.is_admin %> +
+
+ Roles
-
-

- Moderator -

-

Moderators can unilaterally close and delete posts, can feature and lock posts - and may impose restrictions on user accounts.

+
+
+
+ +
+
+

+ Moderator +

+

Moderators can unilaterally close and delete posts, can feature and lock posts + and may impose restrictions on user accounts.

+
+
+ <% if @user.is_moderator %> + + <% else %> + + <% end %> +
+
-
- <% if @user.is_moderator %> - - <% else %> - - <% end %> + <% end %> + <% if current_user.is_global_admin %> +
+
+
+ +
+
+

+ Administrator +

+

Administrators can edit site settings and user roles.

+
+
+ <% if @user.is_global_moderator %> + + <% else %> + + <% end %> +
-
-<% end %> -<% if current_user.is_global_admin %> -
-
-
- -
-
-

- Administrator -

-

Administrators can edit site settings and user roles.

-
-
- <% if @user.is_global_moderator %> - - <% else %> - - <% end %> -
-
-
-
-
-
- -
-
-

- Network-wide Moderator -

-

This user will have moderator status on every site in this network.

-
-
- <% if @user.is_global_moderator %> - - <% else %> - - <% end %> -
-
-
-
-
-
- -
-
-

- Network-wide Admin -

-

This user will have admin status on every site in this network.

-
-
- <% if @user.is_global_admin %> - <% if @user.id == current_user.id %> - - <% else %> - - <% end %> - <% else %> - - <% end %> -
-
-
-<% end %> -<% if current_user.is_global_admin && current_user.staff? %> -
-
-
- +
+
+
+ +
+
+

+ Network-wide Moderator +

+

This user will have moderator status on every site in this network.

+
+
+ <% if @user.is_global_moderator %> + + <% else %> + + <% end %> +
+
-
-

- Staff -

-

The staff role doesn't carry any privileges, but designates the staff running this - site.

+
+
+
+ +
+
+

+ Network-wide Admin +

+

This user will have admin status on every site in this network.

+
+
+ <% if @user.is_global_admin %> + <% if @user.id == current_user.id %> + + <% else %> + + <% end %> + <% else %> + + <% end %> +
+
-
- <% if @user.staff? %> - - <% else %> - - <% end %> + <% end %> + <% if current_user.is_global_admin && current_user.staff? %> +
+
+
+ +
+
+

+ Staff +

+

The staff role doesn't carry any privileges, but designates the staff running this + site.

+
+
+ <% if @user.staff? %> + + <% else %> + + <% end %> +
+
+ <% end %>
+
-<% end %> -
+
\ No newline at end of file diff --git a/app/views/users/mod_reset_profile.html.erb b/app/views/users/mod_reset_profile.html.erb new file mode 100644 index 000000000..b8cf5b597 --- /dev/null +++ b/app/views/users/mod_reset_profile.html.erb @@ -0,0 +1,23 @@ +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> + +<%= render 'tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Clear User Profile

+

As a moderator you may clear user profiles which are in violation of the rules of this site.

+

This will clear the following fields:

+
    +
  • user name
  • +
  • bio
  • +
  • website
  • +
  • twitter
  • +
  • discord
  • +
+

The user will receive a notification about this action.

+ <%= link_to 'Clear Profile', mod_clear_profile_path(@user.id), + method: :post, class: 'button is-danger is-outlined' %> +
+
\ No newline at end of file diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 1ce3f9975..d21ca321c 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -8,13 +8,18 @@

<%= rtl_safe_username(@user) %>

-<% if @user.community_user.suspended? %> +<% if @user.globally_suspended? %>
- <% if @user.community_user.suspension_public_comment.nil? %> -

This user has been temporarily suspended. - <% else %> -

This user has been temporarily suspended <%= @user.community_user.suspension_public_comment %>. - <% end %> +

This user has been temporarily suspended network-wide. + The suspension ends in <%= time_ago_in_words(@user.global_suspension_end) %>.

+
+<% elsif @user.community_user.suspended? %> +
+ <% if @user.community_user.suspension_public_comment.nil? %> +

This user has been temporarily suspended. + <% else %> +

This user has been temporarily suspended <%= @user.community_user.suspension_public_comment %>. + <% end %> The suspension ends in <%= time_ago_in_words(@user.community_user.suspension_end) %>.

@@ -22,7 +27,7 @@
-
+
<% effective_profile = raw(sanitize(@user.profile&.strip || '', scrubber: scrubber)) %> @@ -71,26 +76,6 @@ Subscribe to user <% end %> <% end %> - <% if current_user&.is_moderator %> - Moderator Tools <% if @user.community_user.mod_warnings&.size.positive? %> (<%= pluralize(@user.community_user.mod_warnings.count, 'message') %>) <% end %> - - <% end %> <% if current_user&.same_as?(@user) %> <%= link_to qr_login_code_path, class: 'button is-outlined is-small' do %> Mobile Sign In diff --git a/config/routes.rb b/config/routes.rb index 5a9c2f225..561f55612 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,8 @@ post 'settings/:name', to: 'site_settings#update', as: :update_site_setting delete 'users/delete/:id', to: 'users#soft_delete', as: :soft_delete_user + get 'users/suspend/:user_id', to: 'mod_warning#new_global', as: :new_global_warning + post 'users/suspend/:user_id', to: 'mod_warning#create_global', as: :create_global_warning get 'privileges', to: 'admin#privileges', as: :admin_privileges get 'privileges/:name', to: 'admin#show_privilege', as: :admin_privilege @@ -93,7 +95,6 @@ get 'flags', to: 'flags#queue', as: :flag_queue get 'flags/handled', to: 'flags#handled', as: :handled_flags get 'flags/escalated', to: 'flags#escalated_queue', as: :escalated_flags - delete 'users/destroy/:id', to: 'users#destroy', as: :destroy_user get 'users/votes/:id', to: 'moderator#user_vote_summary', as: :mod_vote_summary post 'flags/:id/resolve', to: 'flags#resolve', as: :resolve_flag post 'flags/:id/escalate', to: 'flags#escalate', as: :escalate_flag @@ -204,6 +205,8 @@ get '/:id/flags', to: 'flags#history', as: :flag_history get '/:id/activity', to: 'users#activity', as: :user_activity get '/:id/mod', to: 'users#mod', as: :mod_user + get '/:id/mod/profile-reset', to: 'users#mod_reset_profile', as: :mod_reset_profile + post '/:id/mod/profile-reset', to: 'users#mod_clear_profile', as: :mod_clear_profile get '/:id/posts', to: 'users#posts', as: :user_posts get '/:id/vote-summary', to: 'users#vote_summary', as: :vote_summary get '/:id/network', to: 'users#network', as: :network @@ -213,8 +216,16 @@ get '/:id/mod/annotations', to: 'users#annotations', as: :user_annotations post '/:id/mod/annotations', to: 'users#annotate', as: :annotate_user get '/:id/mod/activity-log', to: 'users#full_log', as: :full_user_log - post '/:id/hellban', to: 'admin#hellban', as: :hellban_user + get '/:id/mod/global-log', to: 'users#global_log', as: :global_user_log + post '/:id/failban', to: 'admin#failban', as: :failban_user + get '/:id/mod/delete', to: 'users#mod_delete', as: :mod_delete + get '/:id/mod/delete-network-account', to: 'users#mod_delete_network_account', as: :mod_delete_network_account + get '/:id/mod/failban', to: 'users#mod_failban', as: :mod_failban get '/:id/avatar/:size', to: 'users#avatar', as: :user_auto_avatar + get '/:id/mod/escalate', to: 'users#mod_escalation', as: :user_escalation + post '/:id/mod/escalate', to: 'users#mod_escalate', as: :user_escalate + get '/:id/mod/contact', to: 'users#mod_contact', as: :mod_contact + post '/:id/mod/contact', to: 'users#mod_message', as: :mod_message end post 'notifications/:id/read', to: 'notifications#read', as: :read_notifications diff --git a/db/migrate/20220205194103_add_global_suspensions.rb b/db/migrate/20220205194103_add_global_suspensions.rb new file mode 100644 index 000000000..31271ed12 --- /dev/null +++ b/db/migrate/20220205194103_add_global_suspensions.rb @@ -0,0 +1,7 @@ +class AddGlobalSuspensions < ActiveRecord::Migration[5.2] + def change + add_column :warnings, :is_global, :boolean, default: false + add_column :users, :is_globally_suspended, :boolean, default: false + add_column :users, :global_suspension_end, :datetime + end +end diff --git a/db/migrate/20220205204928_add_user_to_warnings.rb b/db/migrate/20220205204928_add_user_to_warnings.rb new file mode 100644 index 000000000..55cf57761 --- /dev/null +++ b/db/migrate/20220205204928_add_user_to_warnings.rb @@ -0,0 +1,6 @@ +class AddUserToWarnings < ActiveRecord::Migration[5.2] + def change + add_reference :warnings, :user, null: true + change_column_null :warnings, :community_user_id, true + end +end diff --git a/db/migrate/20220206213138_add_detached_and_private_comment_threads.rb b/db/migrate/20220206213138_add_detached_and_private_comment_threads.rb new file mode 100644 index 000000000..90591b3d6 --- /dev/null +++ b/db/migrate/20220206213138_add_detached_and_private_comment_threads.rb @@ -0,0 +1,6 @@ +class AddDetachedAndPrivateCommentThreads < ActiveRecord::Migration[5.2] + def change + change_column_null :comment_threads, :post_id, true + add_column :comment_threads, :is_private, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 97e097dba..162f4f001 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -175,6 +175,7 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.bigint "community_id", null: false + t.boolean "is_private", default: false t.index ["archived_by_id"], name: "index_comment_threads_on_archived_by_id" t.index ["community_id"], name: "index_comment_threads_on_community_id" t.index ["deleted_by_id"], name: "index_comment_threads_on_deleted_by_id" @@ -757,6 +758,8 @@ t.datetime "deleted_at", precision: nil t.bigint "deleted_by_id" t.string "backup_2fa_code" + t.boolean "is_globally_suspended", default: false + t.datetime "global_suspension_end", precision: nil t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["deleted_by_id"], name: "index_users_on_deleted_by_id" t.index ["email"], name: "index_users_on_email", unique: true @@ -797,8 +800,11 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.boolean "read", default: false + t.boolean "is_global", default: false + t.bigint "user_id" t.index ["author_id"], name: "index_warnings_on_author_id" t.index ["community_user_id"], name: "index_warnings_on_community_user_id" + t.index ["user_id"], name: "index_warnings_on_user_id" end add_foreign_key "abilities", "communities" diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 0aa5d0863..bf475be52 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -58,28 +58,6 @@ class UsersControllerTest < ActionController::TestCase assert_response(404) end - test 'should destroy user' do - sign_in users(:global_admin) - delete :destroy, params: { id: users(:standard_user).id } - assert_not_nil assigns(:user) - assert_equal 'success', JSON.parse(response.body)['status'] - assert_response(200) - end - - test 'should require authentication to destroy user' do - sign_out :user - delete :destroy, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(404) - end - - test 'should require moderator status to destroy user' do - sign_in users(:standard_user) - delete :destroy, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(404) - end - test 'should soft-delete user' do sign_in users(:global_admin) delete :soft_delete, params: { id: users(:standard_user).id, type: 'user' }