From f16bd3acd6c7a31e448c230a2ee4209d7ed7ff4c Mon Sep 17 00:00:00 2001 From: trichoplax Date: Thu, 23 Apr 2026 15:26:06 +0100 Subject: [PATCH 1/9] Temporary commit to confirm what should be cherry picked from the mod tools branch --- app/assets/javascripts/application.js | 9 + app/assets/stylesheets/application.scss | 31 ++ app/assets/stylesheets/tabs.scss | 6 +- app/assets/stylesheets/users.scss | 15 + app/controllers/admin_controller.rb | 6 +- app/controllers/moderator_controller.rb | 1 + app/controllers/users_controller.rb | 24 +- app/views/mod_warning/log.html.erb | 89 ++--- app/views/mod_warning/new.html.erb | 135 ++++---- .../moderator/user_vote_summary.html.erb | 107 +++--- app/views/shared/_user_mod_sidebar.html.erb | 88 +++++ app/views/users/_tabs.html.erb | 18 + app/views/users/annotations.html.erb | 62 ++-- app/views/users/full_log.html.erb | 60 ++-- app/views/users/mod.html.erb | 223 ++++++++++-- app/views/users/mod_delete.html.erb | 27 ++ .../users/mod_delete_network_account.html.erb | 27 ++ app/views/users/mod_failban.html.erb | 26 ++ app/views/users/mod_privileges.html.erb | 325 +++++++++--------- app/views/users/show.html.erb | 23 +- config/routes.rb | 5 +- test/controllers/admin_controller_test.rb | 8 +- 22 files changed, 884 insertions(+), 431 deletions(-) create mode 100644 app/views/shared/_user_mod_sidebar.html.erb create mode 100644 app/views/users/mod_delete.html.erb create mode 100644 app/views/users/mod_delete_network_account.html.erb create mode 100644 app/views/users/mod_failban.html.erb diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 4eb8247fb..1c80254af 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -37,6 +37,15 @@ document.addEventListener('DOMContentLoaded', async () => { dialog.classList.toggle('is-active'); }); + QPixel.DOM.addSelectorListener('click', '.is-partial-only:not(.open)', (ev) => { + if (ev.target.classList.contains("open")) { + return; + } + + ev.target.classList.add("open"); + ev.stopPropagation(); + }); + if (document.cookie.indexOf('dismiss_fvn') === -1) { QPixel.DOM.addSelectorListener('click', '#fvn-dismiss', (_ev) => { 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 e178458a3..441471e64 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -265,3 +265,34 @@ kbd { right: 0.15em; }; } + +.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; + } +} diff --git a/app/assets/stylesheets/tabs.scss b/app/assets/stylesheets/tabs.scss index 108168625..ce2c1345a 100644 --- a/app/assets/stylesheets/tabs.scss +++ b/app/assets/stylesheets/tabs.scss @@ -2,4 +2,8 @@ .tabs { margin-bottom: 1em; -} \ No newline at end of file + + .tabs--push { + flex-grow: 1; + } +} diff --git a/app/assets/stylesheets/users.scss b/app/assets/stylesheets/users.scss index 90d9fadc2..7dc2ce359 100644 --- a/app/assets/stylesheets/users.scss +++ b/app/assets/stylesheets/users.scss @@ -241,3 +241,18 @@ $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; } diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index f17661088..b5aace580 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -2,9 +2,9 @@ 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, :all_email, :send_all_email] + :setup_save, :failban, :all_email, :send_all_email] before_action :verify_developer, only: [:change_users, :impersonate] - before_action :set_user, only: [:change_users, :hellban, :impersonate] + before_action :set_user, only: [:change_users, :failban, :impersonate] skip_before_action :check_if_warning_or_suspension_pending, only: [:change_back, :verify_elevation] @@ -204,7 +204,7 @@ def setup_save render end - def hellban + def failban @user.block("user manually blocked by admin ##{current_user.id}") flash[:success] = t 'admin.user_fed_stat' redirect_back fallback_location: admin_path diff --git a/app/controllers/moderator_controller.rb b/app/controllers/moderator_controller.rb index c2f649e7f..60e5ee4fe 100644 --- a/app/controllers/moderator_controller.rb +++ b/app/controllers/moderator_controller.rb @@ -69,6 +69,7 @@ def user_vote_summary total: Vote.for(@user).count ) ) + render layout: 'without_sidebar' end def spammy_users diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9a81f07bc..65e5777d8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -12,9 +12,10 @@ class UsersController < ApplicationController before_action :redirect_to_sign_in, only: [:filters], unless: [:user_signed_in?, :json_request?] - 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, + before_action :verify_moderator, only: [:mod, :soft_delete, :role_toggle, :full_log, + :annotate, :annotations, :mod_privileges, :mod_privilege_action, :mod_delete,] + before_action :verify_global_moderator, only: [:mod_failban, :mod_delete_network_account] + before_action :set_user, only: [:show, :mod, :mod_delete, :mod_failban, :mod_delete_network_account, :soft_delete, :posts, :role_toggle, :full_log, :activity, :annotate, :annotations, :mod_privileges, :mod_privilege_action, :vote_summary, :network, :avatar] before_action :check_deleted, only: [:show, :posts, :activity] @@ -275,7 +276,9 @@ def activity render layout: 'without_sidebar' end - def mod; end + def mod + render layout: 'without_sidebar' + end def full_log @posts = Post.by(@user).count @@ -319,8 +322,21 @@ def full_log render layout: 'without_sidebar' end + def mod_delete_network_account + render layout: 'without_sidebar' + end + + def mod_failban + render layout: 'without_sidebar' + end + def mod_privileges @abilities = Ability.all + render layout: 'without_sidebar' + end + + def mod_delete + render layout: 'without_sidebar' end def soft_delete diff --git a/app/views/mod_warning/log.html.erb b/app/views/mod_warning/log.html.erb index 5a019feb4..9ba5c5b89 100644 --- a/app/views/mod_warning/log.html.erb +++ b/app/views/mod_warning/log.html.erb @@ -1,42 +1,49 @@ -

Warnings sent to <%= user_link @user %>

+<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> - - - - - - - - - <% @warnings.each do |w| %> - - - - - - - - <% end %> -
DateTypeFromExcerptStatus
- <%= time_ago_in_words(w.created_at) %> ago - - <% if w.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 %> -
+<%= render 'users/tabs', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Previously Sent Warnings

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

No warnings found for this user.

+ <% end %> + + <% @warnings.each do |w| %> +
+
+
+ <% 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 + <% 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 %> +
+
diff --git a/app/views/mod_warning/new.html.erb b/app/views/mod_warning/new.html.erb index 63f91ca38..797164d94 100644 --- a/app/views/mod_warning/new.html.erb +++ b/app/views/mod_warning/new.html.erb @@ -2,80 +2,93 @@ <%= render 'posts/markdown_script' %> <% end %> -<%= render 'posts/image_upload' %> +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> -

Warn or suspend <%= user_link @user %>

+<%= render 'users/tabs', user: @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.

-
+
+ <%= render 'shared/user_mod_sidebar', user: @user %> + +
+

Warn or Suspend User

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

Use the warning tool only against users who have violated the site rules. Prefer other measures where possible, such as a public + comment.

-
-
-

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

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

+
+
+

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' %> -
+
+ <%= 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.

+
+ 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 %> + <% 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 :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_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' %> +
+ <%= 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 %> + +
-<% end %> diff --git a/app/views/moderator/user_vote_summary.html.erb b/app/views/moderator/user_vote_summary.html.erb index 65fb666a3..9c2d3e7b1 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 %> +
+
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..6c890e4c8 --- /dev/null +++ b/app/views/shared/_user_mod_sidebar.html.erb @@ -0,0 +1,88 @@ +
+
+
+ User Moderation Tools +
+
+
+ <%= render 'users/common_card', user: user, ckb: false %> +
+
+
+ +
+
+
diff --git a/app/views/users/_tabs.html.erb b/app/views/users/_tabs.html.erb index fb2c9d191..8047eae2b 100644 --- a/app/views/users/_tabs.html.erb +++ b/app/views/users/_tabs.html.erb @@ -28,4 +28,22 @@ <%= link_to network_path(user), class: "tabs--item #{current_page?(network_path(user)) ? 'is-active' : ''}" do %> All Communities <% end %> +
+ <% if current_user&.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_warning_log_path(user)) || + current_page?(new_mod_warning_path(user)) || + current_page?(mod_delete_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 858a5fdab..26c5b784a 100644 --- a/app/views/users/annotations.html.erb +++ b/app/views/users/annotations.html.erb @@ -1,35 +1,39 @@ -<% - title = 'User annotations' -%> +<% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> -<% content_for :title, title %> +<%= render 'tabs', user: @user %> -

<%= title %>

+
+ <%= render 'shared/user_mod_sidebar', user: @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' %> -
+
+

User annotations

- <%= submit_tag 'Save', class: 'button is-filled' %> - <% end %> -
+
+ 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' %> +
-
-

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

-
+ <%= submit_tag 'Save', class: 'button is-filled' %> + <% end %> +
-<%= render 'admin/log_table' %> +
+

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

+
+ + <%= render 'admin/log_table' %> +
+
diff --git a/app/views/users/full_log.html.erb b/app/views/users/full_log.html.erb index bbd01e835..7c1f8cde8 100644 --- a/app/views/users/full_log.html.erb +++ b/app/views/users/full_log.html.erb @@ -1,44 +1,52 @@ <% content_for :title, "Full Activity Log: #{rtl_safe_username(@user)}" %> -

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

+<%= render 'tabs', user: @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.

+
+ <%= render 'shared/user_mod_sidebar', user: @user %> -<% 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 %> +
+

Full activity log

-
- - Show all events - - <% if @interesting > 0 %> - - Negative - <%= @interesting %> - +

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 %> - + + + +
-<%= render 'activity_items', mod: true %> + <%= render 'activity_items', mod: true %> +
+
diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index 647bd7e1e..18562929c 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -1,39 +1,198 @@ <% 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 '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 %> + + + + + + + + + + + + + + + + + +
Annotations + <% if annotations_count > 0 %> + <%= annotations_count %> + <% else %> + 0 + <% end %> +
Currently Suspended? + <% if @user.community_user.suspended? %> + yes + <% else %> + no + <% 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_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_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 ed62efb6d..0f0428ea3 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.admin? %> -
-
- Roles -
-
-
-
- + <% if current_user.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.moderator? %> + + <% else %> + + <% end %> +
+
-
- <% if @user.community_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.global_admin? %> -
-
-
- -
-
-

- Administrator -

-

Administrators can edit site settings and user roles.

-
-
- <% if @user.community_user&.is_admin %> - - <% else %> - - <% end %> -
-
-
-
-
-
- -
-
-

- Network-wide Moderator -

-

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

-
-
- <% if @user.global_moderator? %> - - <% else %> - - <% end %> -
-
-
-
-
-
- -
-
-

- Network-wide Admin -

-

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

-
-
- <% if @user.global_admin? %> - <% if @user.id == current_user.id %> - - <% else %> - - <% end %> - <% else %> - - <% end %> -
-
-
-<% end %> -<% if current_user.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 %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 74a8ae1b4..627f663bb 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -64,27 +64,6 @@ Subscribe to user <% end %> <% end %> - <% if current_user&.at_least_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 @@ -117,7 +96,7 @@
<% if @user.staff? %>
diff --git a/config/routes.rb b/config/routes.rb index fc4c5ca46..276fe870a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -224,7 +224,10 @@ 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 + post '/:id/failban', to: 'admin#failban', as: :failban_user + get '/:id/mod/failban', to: 'users#mod_failban', as: :mod_failban + 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/avatar/:size', to: 'users#avatar', as: :user_auto_avatar end diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index ba2dcbfd4..7ea0f47f2 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -196,11 +196,11 @@ class AdminControllerTest < ActionController::TestCase end end - test 'hellban should correctly block the user' do + test 'failban should correctly block the user' do sign_in users(:global_admin) user = users(:standard_user) - try_hellban_user(user) + try_failban_user(user) user.reload assert_response(:found) @@ -223,8 +223,8 @@ def try_audit_logs(**params) get :audit_logs, params: params end - def try_hellban_user(user) - post :hellban, params: { id: user.id } + def try_failban_user(user) + post :failban, params: { id: user.id } end def try_impersonate_user(user) From 6c29d6d6ffe34df1af33b250a9d1a0a1e309cc24 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Thu, 23 Apr 2026 16:00:20 +0100 Subject: [PATCH 2/9] Add missing image upload modal for warnings --- app/views/mod_warning/new.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/mod_warning/new.html.erb b/app/views/mod_warning/new.html.erb index 797164d94..d45550f27 100644 --- a/app/views/mod_warning/new.html.erb +++ b/app/views/mod_warning/new.html.erb @@ -4,6 +4,7 @@ <% content_for :title, "Moderator Tools: #{rtl_safe_username(@user)}" %> +<%= render 'posts/image_upload' %> <%= render 'users/tabs', user: @user %>
From ed3e5d9b798728b166c438264bee9e69c1acc0a8 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Thu, 23 Apr 2026 18:03:24 +0100 Subject: [PATCH 3/9] Formatting improvements thanks to Rubocop --- app/controllers/users_controller.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 65e5777d8..2af983ee0 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -13,11 +13,11 @@ class UsersController < ApplicationController before_action :redirect_to_sign_in, only: [:filters], unless: [:user_signed_in?, :json_request?] before_action :verify_moderator, only: [:mod, :soft_delete, :role_toggle, :full_log, - :annotate, :annotations, :mod_privileges, :mod_privilege_action, :mod_delete,] + :annotate, :annotations, :mod_privileges, :mod_privilege_action, :mod_delete] before_action :verify_global_moderator, only: [:mod_failban, :mod_delete_network_account] - before_action :set_user, only: [:show, :mod, :mod_delete, :mod_failban, :mod_delete_network_account, :soft_delete, :posts, :role_toggle, :full_log, :activity, - :annotate, :annotations, :mod_privileges, :mod_privilege_action, - :vote_summary, :network, :avatar] + before_action :set_user, only: [:show, :mod, :mod_delete, :mod_failban, :mod_delete_network_account, :soft_delete, + :posts, :role_toggle, :full_log, :activity, :annotate, :annotations, :mod_privileges, + :mod_privilege_action, :vote_summary, :network, :avatar] before_action :check_deleted, only: [:show, :posts, :activity] def index From bcfedf9a181cd9c3bd9c079ccc31f20f854a837a Mon Sep 17 00:00:00 2001 From: trichoplax Date: Fri, 24 Apr 2026 17:43:46 +0100 Subject: [PATCH 4/9] Make 'without_sidebar' the default layout for the users controller --- app/controllers/users_controller.rb | 43 ++++------------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2af983ee0..df5dfc6bc 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -2,6 +2,8 @@ # rubocop:disable Metrics/ClassLength class UsersController < ApplicationController + layout 'without_sidebar' + include Devise::Controllers::Rememberable before_action :authenticate_user!, only: [:edit_profile, :update_profile, :stack_redirect, @@ -38,6 +40,7 @@ def index respond_to do |format| format.html + render layout: 'application' format.json do render json: @users end @@ -61,7 +64,6 @@ def show .count end @posts = @posts.first(@limit) - render layout: 'without_sidebar' end def me @@ -93,7 +95,6 @@ def preferences prefs = current_user.preferences @preferences = prefs[:global] @community_prefs = prefs[:community] - render layout: 'without_sidebar' end format.json do render json: current_user.preferences @@ -136,9 +137,7 @@ def filters_json def filters respond_to do |format| - format.html do - render layout: 'without_sidebar' - end + format.html format.json do render json: filters_json end @@ -226,7 +225,7 @@ def posts respond_to do |format| format.html do - render :posts + render :posts, layout: 'application' end format.json do render json: @posts @@ -240,7 +239,6 @@ def my_network def network @communities = Community.all - render layout: 'without_sidebar' end def my_activity @@ -273,11 +271,6 @@ def activity end @items = items.sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50) - render layout: 'without_sidebar' - end - - def mod - render layout: 'without_sidebar' end def full_log @@ -318,25 +311,10 @@ def full_log SuggestedEdit.by(@user).all + PostHistory.by(@user).all + ModWarning.to(@user).all end).sort_by(&:created_at).reverse.paginate(page: params[:page], per_page: 50) - - render layout: 'without_sidebar' - end - - def mod_delete_network_account - render layout: 'without_sidebar' - end - - def mod_failban - render layout: 'without_sidebar' end def mod_privileges @abilities = Ability.all - render layout: 'without_sidebar' - end - - def mod_delete - render layout: 'without_sidebar' end def soft_delete @@ -365,10 +343,6 @@ def soft_delete render json: { status: 'success', user: @user.id } end - def edit_profile - render layout: 'without_sidebar' - end - def cleaned_profile_websites(profile_params) sites = profile_params[:user_websites_attributes] @@ -591,7 +565,6 @@ def annotations @logs = AuditLog.where(log_type: 'user_annotation', related: @user) .newest_first .paginate(page: params[:page], per_page: 20) - render layout: 'without_sidebar' end def annotate @@ -621,8 +594,6 @@ def vote_summary [k, vl.group_by(&:post), vl.sum { |v| v.vote_type * v.vote_count }] end .paginate(page: params[:page], per_page: 15) - - render layout: 'without_sidebar' end def avatar @@ -644,10 +615,6 @@ def specific_avatar end end - def disconnect_sso - render layout: 'without_sidebar' - end - def confirm_disconnect_sso if current_user.sso_profile.blank? || !helpers.devise_sign_in_enabled? || !SiteSetting['AllowSsoDisconnect'] flash[:danger] = 'You cannot disable Single Sign-On.' From 588c019a4c3887c9c73f9ee51112ff458c719046 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Fri, 24 Apr 2026 17:58:16 +0100 Subject: [PATCH 5/9] Add missing do block (thanks Rubocop!) --- app/controllers/users_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index df5dfc6bc..38ba9eb29 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -39,8 +39,9 @@ def index @post_counts = Post.where(user_id: @users.pluck(:id).uniq).group(:user_id).count respond_to do |format| - format.html + format.html do render layout: 'application' + end format.json do render json: @users end From c2acb3d235fcab50feb001a74b7bb038abae211d Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 27 Apr 2026 19:12:17 +0100 Subject: [PATCH 6/9] Remove expand buttons in mod warning log --- app/assets/javascripts/application.js | 9 ---- app/assets/stylesheets/application.scss | 31 ------------- app/views/mod_warning/log.html.erb | 60 ++++++++++++------------- 3 files changed, 29 insertions(+), 71 deletions(-) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 1c80254af..4eb8247fb 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -37,15 +37,6 @@ document.addEventListener('DOMContentLoaded', async () => { dialog.classList.toggle('is-active'); }); - QPixel.DOM.addSelectorListener('click', '.is-partial-only:not(.open)', (ev) => { - if (ev.target.classList.contains("open")) { - return; - } - - ev.target.classList.add("open"); - ev.stopPropagation(); - }); - if (document.cookie.indexOf('dismiss_fvn') === -1) { QPixel.DOM.addSelectorListener('click', '#fvn-dismiss', (_ev) => { 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 441471e64..e178458a3 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -265,34 +265,3 @@ kbd { right: 0.15em; }; } - -.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; - } -} diff --git a/app/views/mod_warning/log.html.erb b/app/views/mod_warning/log.html.erb index 9ba5c5b89..2aaa4d904 100644 --- a/app/views/mod_warning/log.html.erb +++ b/app/views/mod_warning/log.html.erb @@ -9,41 +9,39 @@

Previously Sent Warnings

<% if @warnings.size == 0 %> -

No warnings found for this user.

+

No warnings found for this user.

<% end %> <% @warnings.each do |w| %> -
-
-
- <% 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 - <% else %> - Warning - <% end %> -
-
<%= time_ago_in_words(w.created_at) %> ago by <%= user_link w.author %>
-
-
- <%= raw(sanitize(render_markdown(w.body), scrubber: scrubber)) %> -
+
+
+
+ <% 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 + <% 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 %>
From ae78b0b739704fa9795badb673348a42014bd5b3 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Mon, 27 Apr 2026 19:45:59 +0100 Subject: [PATCH 7/9] Restore formatting of mod tools previous warning log --- app/views/mod_warning/log.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/mod_warning/log.html.erb b/app/views/mod_warning/log.html.erb index 2aaa4d904..c1c45525a 100644 --- a/app/views/mod_warning/log.html.erb +++ b/app/views/mod_warning/log.html.erb @@ -40,7 +40,9 @@
<%= time_ago_in_words(w.created_at) %> ago by <%= user_link w.author %>
- <%= raw(sanitize(render_markdown(w.body), scrubber: scrubber)) %> +
+ <%= raw(sanitize(render_markdown(w.body), scrubber: scrubber)) %> +
<% end %>
From efd7158b173ee2a54a2cc49bbcf9489bdb8d9e29 Mon Sep 17 00:00:00 2001 From: trichoplax Date: Fri, 15 May 2026 10:19:42 +0100 Subject: [PATCH 8/9] Make user mod tools left panel move to top on mobile --- app/assets/stylesheets/application.scss | 8 ++ app/assets/stylesheets/users.scss | 15 +++ app/controllers/admin_controller.rb | 2 + app/views/admin/impersonate.html.erb | 62 +++++++----- app/views/mod_warning/log.html.erb | 4 +- app/views/mod_warning/new.html.erb | 4 +- .../moderator/user_vote_summary.html.erb | 4 +- .../shared/_user_mod_mobile_list.html.erb | 13 +++ app/views/shared/_user_mod_sidebar.html.erb | 97 +++---------------- .../shared/_user_mod_tools_list.html.erb | 74 ++++++++++++++ app/views/users/annotations.html.erb | 4 +- app/views/users/full_log.html.erb | 4 +- app/views/users/mod.html.erb | 4 +- app/views/users/mod_delete.html.erb | 4 +- .../users/mod_delete_network_account.html.erb | 4 +- app/views/users/mod_failban.html.erb | 4 +- app/views/users/mod_privileges.html.erb | 4 +- 17 files changed, 191 insertions(+), 120 deletions(-) create mode 100644 app/views/shared/_user_mod_mobile_list.html.erb create mode 100644 app/views/shared/_user_mod_tools_list.html.erb diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e178458a3..acef111a7 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -265,3 +265,11 @@ kbd { right: 0.15em; }; } + +.is-9-desktop-12-mobile { + width: 75%; + + @media screen and (max-width:850px) { + width: 100%; + } +} diff --git a/app/assets/stylesheets/users.scss b/app/assets/stylesheets/users.scss index 7dc2ce359..32c54548b 100644 --- a/app/assets/stylesheets/users.scss +++ b/app/assets/stylesheets/users.scss @@ -256,3 +256,18 @@ $sizes: (16, 32, 40, 48, 64, 128, 256); } .mod-warnings-clear-form { display: inline; } + +.modtools--top-list { + display: none; +} + + +@media screen and (max-width: 850px) { + .modtools--top-list { + display: block; + } + + .modtools--sidebar { + display: none; + } +} diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index b5aace580..e24ea4967 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -214,6 +214,8 @@ def impersonate if Rails.env.development? change_users end + + render layout: 'without_sidebar' end def change_users diff --git a/app/views/admin/impersonate.html.erb b/app/views/admin/impersonate.html.erb index 19fe6e3f0..91025e29a 100644 --- a/app/views/admin/impersonate.html.erb +++ b/app/views/admin/impersonate.html.erb @@ -1,31 +1,43 @@ -

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_mobile_list', user: @user %> + +
+ <%= render 'shared/user_mod_sidebar', user: @user %> -<%= 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 %> +
+

Impersonate <%= @user.username %>

+

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

+ +
+

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 %> +
diff --git a/app/views/mod_warning/log.html.erb b/app/views/mod_warning/log.html.erb index c1c45525a..9e06f7d9c 100644 --- a/app/views/mod_warning/log.html.erb +++ b/app/views/mod_warning/log.html.erb @@ -2,10 +2,12 @@ <%= render 'users/tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', user: @user %> +
<%= render 'shared/user_mod_sidebar', user: @user %> -
+

Previously Sent Warnings

<% if @warnings.size == 0 %> diff --git a/app/views/mod_warning/new.html.erb b/app/views/mod_warning/new.html.erb index d45550f27..8195f8890 100644 --- a/app/views/mod_warning/new.html.erb +++ b/app/views/mod_warning/new.html.erb @@ -7,10 +7,12 @@ <%= render 'posts/image_upload' %> <%= render 'users/tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', user: @user %> +
<%= render 'shared/user_mod_sidebar', user: @user %> -
+

Warn or Suspend User

diff --git a/app/views/moderator/user_vote_summary.html.erb b/app/views/moderator/user_vote_summary.html.erb index 9c2d3e7b1..e9936093a 100644 --- a/app/views/moderator/user_vote_summary.html.erb +++ b/app/views/moderator/user_vote_summary.html.erb @@ -2,10 +2,12 @@ <%= render 'users/tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', user: @user %> +
<%= render 'shared/user_mod_sidebar', user: @user %> -
+

Vote Summary

This is a summary of votes cast and received by this user. This may help you to identify voting patterns and diff --git a/app/views/shared/_user_mod_mobile_list.html.erb b/app/views/shared/_user_mod_mobile_list.html.erb new file mode 100644 index 000000000..032c499a4 --- /dev/null +++ b/app/views/shared/_user_mod_mobile_list.html.erb @@ -0,0 +1,13 @@ +

+
+ + User Moderation Tools + + <%= render 'shared/user_mod_tools_list', user: user %> +
+
+
+ <%= render 'users/common_card', user: user, ckb: false %> +
+
+
diff --git a/app/views/shared/_user_mod_sidebar.html.erb b/app/views/shared/_user_mod_sidebar.html.erb index 6c890e4c8..ee9646e80 100644 --- a/app/views/shared/_user_mod_sidebar.html.erb +++ b/app/views/shared/_user_mod_sidebar.html.erb @@ -1,88 +1,15 @@
-
-
- User Moderation Tools -
-
-
- <%= render 'users/common_card', user: user, ckb: false %> -
-
-
- -
+
+
+ User Moderation Tools
+
+
+ <%= render 'users/common_card', user: user, ckb: false %> +
+
+
+ <%= render 'shared/user_mod_tools_list', user: user %> +
+
diff --git a/app/views/shared/_user_mod_tools_list.html.erb b/app/views/shared/_user_mod_tools_list.html.erb new file mode 100644 index 000000000..7a73b3140 --- /dev/null +++ b/app/views/shared/_user_mod_tools_list.html.erb @@ -0,0 +1,74 @@ + diff --git a/app/views/users/annotations.html.erb b/app/views/users/annotations.html.erb index 26c5b784a..2555e8d60 100644 --- a/app/views/users/annotations.html.erb +++ b/app/views/users/annotations.html.erb @@ -2,10 +2,12 @@ <%= render 'tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', user: @user %> +
<%= render 'shared/user_mod_sidebar', user: @user %> -
+

User annotations

diff --git a/app/views/users/full_log.html.erb b/app/views/users/full_log.html.erb index 7c1f8cde8..8ece5fb67 100644 --- a/app/views/users/full_log.html.erb +++ b/app/views/users/full_log.html.erb @@ -2,10 +2,12 @@ <%= render 'tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', 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.

diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index 18562929c..17453185c 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -2,10 +2,12 @@ <%= render 'tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', user: @user %> +
<%= 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.

diff --git a/app/views/users/mod_delete.html.erb b/app/views/users/mod_delete.html.erb index 518f10a0b..086dc9ecc 100644 --- a/app/views/users/mod_delete.html.erb +++ b/app/views/users/mod_delete.html.erb @@ -2,10 +2,12 @@ <%= render 'tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', 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.

diff --git a/app/views/users/mod_delete_network_account.html.erb b/app/views/users/mod_delete_network_account.html.erb index 6df3c2409..42a1dbb03 100644 --- a/app/views/users/mod_delete_network_account.html.erb +++ b/app/views/users/mod_delete_network_account.html.erb @@ -2,10 +2,12 @@ <%= render 'tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', 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.

diff --git a/app/views/users/mod_failban.html.erb b/app/views/users/mod_failban.html.erb index b7a2b95ce..22208e071 100644 --- a/app/views/users/mod_failban.html.erb +++ b/app/views/users/mod_failban.html.erb @@ -2,10 +2,12 @@ <%= render 'tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', user: @user %> +
<%= render 'shared/user_mod_sidebar', user: @user %> -
+

Fail-ban

diff --git a/app/views/users/mod_privileges.html.erb b/app/views/users/mod_privileges.html.erb index 0f0428ea3..380260c04 100644 --- a/app/views/users/mod_privileges.html.erb +++ b/app/views/users/mod_privileges.html.erb @@ -2,10 +2,12 @@ <%= render 'tabs', user: @user %> +<%= render 'shared/user_mod_mobile_list', user: @user %> +
<%= render 'shared/user_mod_sidebar', user: @user %> -
+

User Privileges

From 126c2c414c35cce79b57f84c2fffd85b18a1deda Mon Sep 17 00:00:00 2001 From: trichoplax Date: Fri, 15 May 2026 21:52:00 +0100 Subject: [PATCH 9/9] Only render once in impersonate method --- app/controllers/admin_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index e24ea4967..7615464c8 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -213,9 +213,9 @@ def failban def impersonate if Rails.env.development? change_users + else + render layout: 'without_sidebar' end - - render layout: 'without_sidebar' end def change_users