about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2018-12-22 20:02:09 +0100
committerGitHub <noreply@github.com>2018-12-22 20:02:09 +0100
commit3c033c4352f8b156887cd7157b4a89c23a545838 (patch)
treefa6317223a0104abea84a10e6234a0beef316001 /app
parent00862dcaff7cb918d29947accda1c01873a7ddeb (diff)
Add moderation warnings (#9519)
* Add moderation warnings

Replace individual routes for disabling, silencing, and suspending
a user, as well as the report update route, with a unified account
action controller that allows you to select an action (none,
disable, silence, suspend) as well as whether it should generate an
e-mail notification with optional custom text. That notification,
with the optional custom text, is saved as a warning.

Additionally, there are warning presets you can configure to save
time when performing the above.

* Use Account#local_username_and_domain
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/account_actions_controller.rb36
-rw-r--r--app/controllers/admin/account_moderation_notes_controller.rb1
-rw-r--r--app/controllers/admin/accounts_controller.rb23
-rw-r--r--app/controllers/admin/reports_controller.rb79
-rw-r--r--app/controllers/admin/silences_controller.rb27
-rw-r--r--app/controllers/admin/suspensions_controller.rb60
-rw-r--r--app/controllers/admin/warning_presets_controller.rb58
-rw-r--r--app/helpers/admin/action_logs_helper.rb7
-rw-r--r--app/javascript/images/icon_flag.svg4
-rw-r--r--app/javascript/images/mailer/icon_warning.pngbin0 -> 371 bytes
-rw-r--r--app/javascript/styles/mailer.scss4
-rw-r--r--app/javascript/styles/mastodon/admin.scss4
-rw-r--r--app/mailers/user_mailer.rb12
-rw-r--r--app/models/account.rb8
-rw-r--r--app/models/account_warning.rb23
-rw-r--r--app/models/account_warning_preset.rb15
-rw-r--r--app/models/admin/account_action.rb134
-rw-r--r--app/models/concerns/account_associations.rb2
-rw-r--r--app/models/form/admin_suspension_confirmation.rb7
-rw-r--r--app/policies/account_policy.rb4
-rw-r--r--app/policies/account_warning_preset_policy.rb19
-rw-r--r--app/views/admin/account_actions/new.html.haml26
-rw-r--r--app/views/admin/account_warnings/_account_warning.html.haml6
-rw-r--r--app/views/admin/accounts/show.html.haml14
-rw-r--r--app/views/admin/reports/show.html.haml17
-rw-r--r--app/views/admin/suspensions/new.html.haml25
-rw-r--r--app/views/admin/warning_presets/edit.html.haml11
-rw-r--r--app/views/admin/warning_presets/index.html.haml30
-rw-r--r--app/views/user_mailer/warning.html.haml63
-rw-r--r--app/views/user_mailer/warning.text.erb9
-rw-r--r--app/views/user_mailer/welcome.text.erb2
31 files changed, 533 insertions, 197 deletions
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
new file mode 100644
index 000000000..e847495f1
--- /dev/null
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Admin
+  class AccountActionsController < BaseController
+    before_action :set_account
+
+    def new
+      @account_action  = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true)
+      @warning_presets = AccountWarningPreset.all
+    end
+
+    def create
+      account_action                 = Admin::AccountAction.new(resource_params)
+      account_action.target_account  = @account
+      account_action.current_account = current_account
+
+      account_action.save!
+
+      if account_action.with_report?
+        redirect_to admin_report_path(account_action.report)
+      else
+        redirect_to admin_account_path(@account.id)
+      end
+    end
+
+    private
+
+    def set_account
+      @account = Account.find(params[:account_id])
+    end
+
+    def resource_params
+      params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification)
+    end
+  end
+end
diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb
index 7d5b9bf52..44f6e34f8 100644
--- a/app/controllers/admin/account_moderation_notes_controller.rb
+++ b/app/controllers/admin/account_moderation_notes_controller.rb
@@ -14,6 +14,7 @@ module Admin
       else
         @account          = @account_moderation_note.target_account
         @moderation_notes = @account.targeted_moderation_notes.latest
+        @warnings         = @account.targeted_account_warnings.latest.custom
 
         render template: 'admin/accounts/show'
       end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 771302db8..10abd1e6a 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -2,9 +2,9 @@
 
 module Admin
   class AccountsController < BaseController
-    before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :disable, :memorialize]
+    before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :memorialize]
     before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload]
-    before_action :require_local_account!, only: [:enable, :disable, :memorialize]
+    before_action :require_local_account!, only: [:enable, :memorialize]
 
     def index
       authorize :account, :index?
@@ -13,8 +13,10 @@ module Admin
 
     def show
       authorize @account, :show?
+
       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
-      @moderation_notes = @account.targeted_moderation_notes.latest
+      @moderation_notes        = @account.targeted_moderation_notes.latest
+      @warnings                = @account.targeted_account_warnings.latest.custom
     end
 
     def subscribe
@@ -43,10 +45,17 @@ module Admin
       redirect_to admin_account_path(@account.id)
     end
 
-    def disable
-      authorize @account.user, :disable?
-      @account.user.disable!
-      log_action :disable, @account.user
+    def unsilence
+      authorize @account, :unsilence?
+      @account.unsilence!
+      log_action :unsilence, @account
+      redirect_to admin_account_path(@account.id)
+    end
+
+    def unsuspend
+      authorize @account, :unsuspend?
+      @account.unsuspend!
+      log_action :unsuspend, @account
       redirect_to admin_account_path(@account.id)
     end
 
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index e97ddb9b6..f138376b2 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -13,75 +13,42 @@ module Admin
       authorize @report, :show?
 
       @report_note  = @report.notes.new
-      @report_notes = (@report.notes.latest + @report.history).sort_by(&:created_at)
+      @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
       @form         = Form::StatusBatch.new
     end
 
-    def update
+    def assign_to_self
       authorize @report, :update?
-      process_report
-
-      if @report.action_taken?
-        redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
-      else
-        redirect_to admin_report_path(@report)
-      end
+      @report.update!(assigned_account_id: current_account.id)
+      log_action :assigned_to_self, @report
+      redirect_to admin_report_path(@report)
     end
 
-    private
-
-    def process_report
-      case params[:outcome].to_s
-      when 'assign_to_self'
-        @report.update!(assigned_account_id: current_account.id)
-        log_action :assigned_to_self, @report
-      when 'unassign'
-        @report.update!(assigned_account_id: nil)
-        log_action :unassigned, @report
-      when 'reopen'
-        @report.unresolve!
-        log_action :reopen, @report
-      when 'resolve'
-        @report.resolve!(current_account)
-        log_action :resolve, @report
-      when 'disable'
-        @report.resolve!(current_account)
-        @report.target_account.user.disable!
-
-        log_action :resolve, @report
-        log_action :disable, @report.target_account.user
-
-        resolve_all_target_account_reports
-      when 'silence'
-        @report.resolve!(current_account)
-        @report.target_account.update!(silenced: true)
-
-        log_action :resolve, @report
-        log_action :silence, @report.target_account
-
-        resolve_all_target_account_reports
-      else
-        raise ActiveRecord::RecordNotFound
-      end
-
-      @report.reload
+    def unassign
+      authorize @report, :update?
+      @report.update!(assigned_account_id: nil)
+      log_action :unassigned, @report
+      redirect_to admin_report_path(@report)
     end
 
-    def resolve_all_target_account_reports
-      unresolved_reports_for_target_account.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
+    def reopen
+      authorize @report, :update?
+      @report.unresolve!
+      log_action :reopen, @report
+      redirect_to admin_report_path(@report)
     end
 
-    def unresolved_reports_for_target_account
-      Report.where(
-        target_account: @report.target_account
-      ).unresolved
+    def resolve
+      authorize @report, :update?
+      @report.resolve!(current_account)
+      log_action :resolve, @report
+      redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
     end
 
+    private
+
     def filtered_reports
-      ReportFilter.new(filter_params).results.order(id: :desc).includes(
-        :account,
-        :target_account
-      )
+      ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account)
     end
 
     def filter_params
diff --git a/app/controllers/admin/silences_controller.rb b/app/controllers/admin/silences_controller.rb
deleted file mode 100644
index 4c06a9c0c..000000000
--- a/app/controllers/admin/silences_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class SilencesController < BaseController
-    before_action :set_account
-
-    def create
-      authorize @account, :silence?
-      @account.update!(silenced: true)
-      log_action :silence, @account
-      redirect_to admin_accounts_path
-    end
-
-    def destroy
-      authorize @account, :unsilence?
-      @account.update!(silenced: false)
-      log_action :unsilence, @account
-      redirect_to admin_accounts_path
-    end
-
-    private
-
-    def set_account
-      @account = Account.find(params[:account_id])
-    end
-  end
-end
diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb
deleted file mode 100644
index f9bbf36fb..000000000
--- a/app/controllers/admin/suspensions_controller.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class SuspensionsController < BaseController
-    before_action :set_account
-
-    def new
-      @suspension = Form::AdminSuspensionConfirmation.new(report_id: params[:report_id])
-    end
-
-    def create
-      authorize @account, :suspend?
-
-      @suspension = Form::AdminSuspensionConfirmation.new(suspension_params)
-
-      if suspension_params[:acct] == @account.acct
-        resolve_report! if suspension_params[:report_id].present?
-        perform_suspend!
-        mark_reports_resolved!
-        redirect_to admin_accounts_path
-      else
-        flash.now[:alert] = I18n.t('admin.suspensions.bad_acct_msg')
-        render :new
-      end
-    end
-
-    def destroy
-      authorize @account, :unsuspend?
-      @account.unsuspend!
-      log_action :unsuspend, @account
-      redirect_to admin_accounts_path
-    end
-
-    private
-
-    def set_account
-      @account = Account.find(params[:account_id])
-    end
-
-    def suspension_params
-      params.require(:form_admin_suspension_confirmation).permit(:acct, :report_id)
-    end
-
-    def resolve_report!
-      report = Report.find(suspension_params[:report_id])
-      report.resolve!(current_account)
-      log_action :resolve, report
-    end
-
-    def perform_suspend!
-      @account.suspend!
-      Admin::SuspensionWorker.perform_async(@account.id)
-      log_action :suspend, @account
-    end
-
-    def mark_reports_resolved!
-      Report.where(target_account: @account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
-    end
-  end
-end
diff --git a/app/controllers/admin/warning_presets_controller.rb b/app/controllers/admin/warning_presets_controller.rb
new file mode 100644
index 000000000..37be842c5
--- /dev/null
+++ b/app/controllers/admin/warning_presets_controller.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Admin
+  class WarningPresetsController < BaseController
+    before_action :set_warning_preset, except: [:index, :create]
+
+    def index
+      authorize :account_warning_preset, :index?
+
+      @warning_presets = AccountWarningPreset.all
+      @warning_preset  = AccountWarningPreset.new
+    end
+
+    def create
+      authorize :account_warning_preset, :create?
+
+      @warning_preset = AccountWarningPreset.new(warning_preset_params)
+
+      if @warning_preset.save
+        redirect_to admin_warning_presets_path
+      else
+        @warning_presets = AccountWarningPreset.all
+        render :index
+      end
+    end
+
+    def edit
+      authorize @warning_preset, :update?
+    end
+
+    def update
+      authorize @warning_preset, :update?
+
+      if @warning_preset.update(warning_preset_params)
+        redirect_to admin_warning_presets_path
+      else
+        render :edit
+      end
+    end
+
+    def destroy
+      authorize @warning_preset, :destroy?
+
+      @warning_preset.destroy!
+      redirect_to admin_warning_presets_path
+    end
+
+    private
+
+    def set_warning_preset
+      @warning_preset = AccountWarningPreset.find(params[:id])
+    end
+
+    def warning_preset_params
+      params.require(:account_warning_preset).permit(:text)
+    end
+  end
+end
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 68cf8c75d..359d60b60 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -23,6 +23,8 @@ module Admin::ActionLogsHelper
       link_to record.domain, "https://#{record.domain}"
     when 'Status'
       link_to record.account.acct, TagManager.instance.url_for(record)
+    when 'AccountWarning'
+      link_to record.target_account.acct, admin_account_path(record.target_account_id)
     end
   end
 
@@ -34,6 +36,7 @@ module Admin::ActionLogsHelper
       link_to attributes['domain'], "https://#{attributes['domain']}"
     when 'Status'
       tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
+
       if tmp_status.account
         link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
       else
@@ -81,6 +84,8 @@ module Admin::ActionLogsHelper
       'envelope'
     when 'Status'
       'pencil'
+    when 'AccountWarning'
+      'warning'
     end
   end
 
@@ -104,6 +109,6 @@ module Admin::ActionLogsHelper
   private
 
   def opposite_verbs?(log)
-    %w(DomainBlock EmailDomainBlock).include?(log.target_type)
+    %w(DomainBlock EmailDomainBlock AccountWarning).include?(log.target_type)
   end
 end
diff --git a/app/javascript/images/icon_flag.svg b/app/javascript/images/icon_flag.svg
new file mode 100644
index 000000000..3939c9d2b
--- /dev/null
+++ b/app/javascript/images/icon_flag.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <path d="M0 0h24v24H0z" fill="none"/>
+  <path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z"/>
+</svg>
diff --git a/app/javascript/images/mailer/icon_warning.png b/app/javascript/images/mailer/icon_warning.png
new file mode 100644
index 000000000..7baaac61c
--- /dev/null
+++ b/app/javascript/images/mailer/icon_warning.png
Binary files differdiff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index d83bd4d96..74d1df8ed 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -426,6 +426,10 @@ h5 {
     background: $success-green;
   }
 
+  &.alert-icon td {
+    background: $error-red;
+  }
+
   img {
     max-width: 32px;
     width: 32px;
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index b6c771abf..e8f331932 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -542,6 +542,10 @@ a.name-tag,
     border-left-color: lighten($error-red, 12%);
   }
 
+  &.warning {
+    border-left-color: $gold-star;
+  }
+
   &__bubble {
     padding: 16px;
     padding-left: 14px;
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index aa76b4dfe..8f3a4ab3a 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -78,4 +78,16 @@ class UserMailer < Devise::Mailer
       mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
     end
   end
+
+  def warning(user, warning)
+    @resource = user
+    @warning  = warning
+    @instance = Rails.configuration.x.local_domain
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email,
+           subject: I18n.t("user_mailer.warning.subject.#{@warning.action}", acct: "@#{user.account.local_username_and_domain}"),
+           reply_to: Setting.site_contact_email
+    end
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 5a7a9c580..16ef6c187 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -155,6 +155,14 @@ class Account < ApplicationRecord
     ResolveAccountService.new.call(acct)
   end
 
+  def silence!
+    update!(silenced: true)
+  end
+
+  def unsilence!
+    update!(silenced: false)
+  end
+
   def suspend!
     transaction do
       user&.disable! if local?
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
new file mode 100644
index 000000000..157e6c04d
--- /dev/null
+++ b/app/models/account_warning.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_warnings
+#
+#  id                :bigint(8)        not null, primary key
+#  account_id        :bigint(8)
+#  target_account_id :bigint(8)
+#  action            :integer          default("none"), not null
+#  text              :text             default(""), not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class AccountWarning < ApplicationRecord
+  enum action: %i(none disable silence suspend), _suffix: :action
+
+  belongs_to :account, inverse_of: :account_warnings
+  belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings
+
+  scope :latest, -> { order(created_at: :desc) }
+  scope :custom, -> { where.not(text: '') }
+end
diff --git a/app/models/account_warning_preset.rb b/app/models/account_warning_preset.rb
new file mode 100644
index 000000000..ba8ceabb3
--- /dev/null
+++ b/app/models/account_warning_preset.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: account_warning_presets
+#
+#  id         :bigint(8)        not null, primary key
+#  text       :text             default(""), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class AccountWarningPreset < ApplicationRecord
+  validates :text, presence: true
+end
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
new file mode 100644
index 000000000..84c3f880d
--- /dev/null
+++ b/app/models/admin/account_action.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+class Admin::AccountAction
+  include ActiveModel::Model
+  include AccountableConcern
+  include Authorization
+
+  TYPES = %w(
+    none
+    disable
+    silence
+    suspend
+  ).freeze
+
+  attr_accessor :target_account,
+                :current_account,
+                :type,
+                :text,
+                :report_id,
+                :warning_preset_id,
+                :send_email_notification
+
+  attr_reader :warning
+
+  def save!
+    ApplicationRecord.transaction do
+      process_action!
+      process_warning!
+    end
+
+    queue_email!
+    process_reports!
+  end
+
+  def report
+    @report ||= Report.find(report_id) if report_id.present?
+  end
+
+  def with_report?
+    !report.nil?
+  end
+
+  class << self
+    def types_for_account(account)
+      if account.local?
+        TYPES
+      else
+        TYPES - %w(none disable)
+      end
+    end
+  end
+
+  private
+
+  def process_action!
+    case type
+    when 'disable'
+      handle_disable!
+    when 'silence'
+      handle_silence!
+    when 'suspend'
+      handle_suspend!
+    end
+  end
+
+  def process_warning!
+    return unless warnable?
+
+    authorize(target_account, :warn?)
+
+    @warning = AccountWarning.create!(target_account: target_account,
+                                      account: current_account,
+                                      action: type,
+                                      text: text_for_warning)
+
+    # A log entry is only interesting if the warning contains
+    # custom text from someone. Otherwise it's just noise.
+    log_action(:create, warning) if warning.text.present?
+  end
+
+  def process_reports!
+    return if report_id.blank?
+
+    authorize(report, :update?)
+
+    if type == 'none'
+      log_action(:resolve, report)
+      report.resolve!(current_account)
+    else
+      Report.where(target_account: target_account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
+    end
+  end
+
+  def handle_disable!
+    authorize(target_account.user, :disable?)
+    log_action(:disable, target_account.user)
+    target_account.user&.disable!
+  end
+
+  def handle_silence!
+    authorize(target_account, :silence?)
+    log_action(:silence, target_account)
+    target_account.silence!
+  end
+
+  def handle_suspend!
+    authorize(target_account, :suspend?)
+    log_action(:suspend, target_account)
+    target_account.suspend!
+    queue_suspension_worker!
+  end
+
+  def text_for_warning
+    [warning_preset&.text, text].compact.join("\n\n")
+  end
+
+  def queue_suspension_worker!
+    Admin::SuspensionWorker.perform_async(target_account.id)
+  end
+
+  def queue_email!
+    return unless warnable?
+
+    UserMailer.warning(target_account.user, warning).deliver_later!
+  end
+
+  def warnable?
+    send_email_notification && target_account.local?
+  end
+
+  def warning_preset
+    @warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index ae50860ed..a894b5eed 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -39,6 +39,8 @@ module AccountAssociations
     # Moderation notes
     has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
     has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
+    has_many :account_warnings, dependent: :destroy, inverse_of: :account
+    has_many :targeted_account_warnings, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
 
     # Lists (that the account is on, not owned by the account)
     has_many :list_accounts, inverse_of: :account, dependent: :destroy
diff --git a/app/models/form/admin_suspension_confirmation.rb b/app/models/form/admin_suspension_confirmation.rb
deleted file mode 100644
index c34b5b30e..000000000
--- a/app/models/form/admin_suspension_confirmation.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class Form::AdminSuspensionConfirmation
-  include ActiveModel::Model
-
-  attr_accessor :acct, :report_id
-end
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 07bae68ef..9c145979d 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -9,6 +9,10 @@ class AccountPolicy < ApplicationPolicy
     staff?
   end
 
+  def warn?
+    staff? && !record.user&.staff?
+  end
+
   def suspend?
     staff? && !record.user&.staff?
   end
diff --git a/app/policies/account_warning_preset_policy.rb b/app/policies/account_warning_preset_policy.rb
new file mode 100644
index 000000000..bccbd33ef
--- /dev/null
+++ b/app/policies/account_warning_preset_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AccountWarningPresetPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def create?
+    staff?
+  end
+
+  def update?
+    staff?
+  end
+
+  def destroy?
+    staff?
+  end
+end
diff --git a/app/views/admin/account_actions/new.html.haml b/app/views/admin/account_actions/new.html.haml
new file mode 100644
index 000000000..97286c8e5
--- /dev/null
+++ b/app/views/admin/account_actions/new.html.haml
@@ -0,0 +1,26 @@
+- content_for :page_title do
+  = t('admin.account_actions.title', acct: @account.acct)
+
+= simple_form_for @account_action, url: admin_account_action_path(@account.id) do |f|
+  = f.input :report_id, as: :hidden
+
+  .fields-group
+    = f.input :type, collection: Admin::AccountAction.types_for_account(@account), include_blank: false, wrapper: :with_block_label, label_method: ->(type) { I18n.t("simple_form.labels.admin_account_action.types.#{type}")}, hint: t('simple_form.hints.admin_account_action.type_html', acct: @account.acct)
+
+  - if @account.local?
+    %hr.spacer/
+
+    .fields-group
+      = f.input :send_email_notification, as: :boolean, wrapper: :with_label
+
+    %hr.spacer/
+
+    - unless @warning_presets.empty?
+      .fields-group
+        = f.input :warning_preset_id, collection: @warning_presets, label_method: :text, wrapper: :with_block_label
+
+    .fields-group
+      = f.input :text, as: :text, wrapper: :with_block_label, hint: t('simple_form.hints.admin_account_action.text_html', path: admin_warning_presets_path)
+
+  .actions
+    = f.button :button, t('admin.account_actions.action'), type: :submit
diff --git a/app/views/admin/account_warnings/_account_warning.html.haml b/app/views/admin/account_warnings/_account_warning.html.haml
new file mode 100644
index 000000000..8c9c9679c
--- /dev/null
+++ b/app/views/admin/account_warnings/_account_warning.html.haml
@@ -0,0 +1,6 @@
+.speech-bubble.warning
+  .speech-bubble__bubble
+    = Formatter.instance.linkify(account_warning.text)
+  .speech-bubble__owner
+    = admin_account_link_to account_warning.account
+    %time.formatted{ datetime: account_warning.created_at.iso8601 }= l account_warning.created_at
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index e9f765107..226aef732 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -64,7 +64,7 @@
               = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user)
             - else
               = t('admin.accounts.enabled')
-              = table_link_to 'lock', t('admin.accounts.disable'), disable_admin_account_path(@account.id), method: :post if can?(:disable, @account.user)
+              = table_link_to 'lock', t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable') if can?(:disable, @account.user)
         %tr
           %th= t('admin.accounts.most_recent_ip')
           %td= @account.user_current_sign_in_ip
@@ -119,18 +119,18 @@
 
   %div{ style: 'float: left' }
     - if @account.silenced?
-      = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button' if can?(:unsilence, @account)
+      = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
     - else
-      = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button button--destructive' if can?(:silence, @account)
+      = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button button--destructive' if can?(:silence, @account)
 
     - if @account.local?
       - unless @account.user_confirmed?
         = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user)
 
     - if @account.suspended?
-      = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button' if can?(:unsuspend, @account)
+      = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
     - else
-      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_suspension_path(@account.id), class: 'button button--destructive' if can?(:suspend, @account)
+      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account)
 
 - if !@account.local? && @account.hub_url.present?
   %hr.spacer/
@@ -184,6 +184,10 @@
 
 %hr.spacer/
 
+= render @warnings
+
+%hr.spacer/
+
 = render @moderation_notes
 
 = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 3588d151d..863dada9e 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -8,13 +8,14 @@
   - if @report.unresolved?
     %div{ style: 'float: right' }
       - if @report.target_account.local?
-        = link_to t('admin.accounts.disable'), admin_report_path(@report, outcome: 'disable'), method: :put, class: 'button button--destructive'
-      = link_to t('admin.accounts.silence'), admin_report_path(@report, outcome: 'silence'), method: :put, class: 'button button--destructive'
-      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_suspension_path(@report.target_account_id, report_id: @report.id), class: 'button button--destructive'
+        = link_to t('admin.accounts.warn'), new_admin_account_action_path(@report.target_account_id, type: 'none', report_id: @report.id), class: 'button'
+        = link_to t('admin.accounts.disable'), new_admin_account_action_path(@report.target_account_id, type: 'disable', report_id: @report.id), class: 'button button--destructive'
+      = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
+      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, type: 'suspend', report_id: @report.id), class: 'button button--destructive'
     %div{ style: 'float: left' }
-      = link_to t('admin.reports.mark_as_resolved'), admin_report_path(@report, outcome: 'resolve'), method: :put, class: 'button'
+      = link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(@report), method: :post, class: 'button'
   - else
-    = link_to t('admin.reports.mark_as_unresolved'), admin_report_path(@report, outcome: 'reopen'), method: :put, class: 'button'
+    = link_to t('admin.reports.mark_as_unresolved'), reopen_admin_report_path(@report), method: :post, class: 'button'
 
 %hr.spacer
 
@@ -67,10 +68,10 @@
               = admin_account_link_to @report.assigned_account
           %td
             - if @report.assigned_account != current_user.account
-              = table_link_to 'user', t('admin.reports.assign_to_self'), admin_report_path(@report, outcome: 'assign_to_self'), method: :put
+              = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
           %td
             - if !@report.assigned_account.nil?
-              = table_link_to 'trash', t('admin.reports.unassign'), admin_report_path(@report, outcome: 'unassign'), method: :put
+              = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
 
 %hr.spacer
 
@@ -104,7 +105,7 @@
 - @report_notes.each do |item|
   - if item.is_a?(Admin::ActionLog)
     = render partial: 'action_log', locals: { action_log: item }
-  - elsif item.is_a?(ReportNote)
+  - else
     = render item
 
 = simple_form_for @report_note, url: admin_report_notes_path do |f|
diff --git a/app/views/admin/suspensions/new.html.haml b/app/views/admin/suspensions/new.html.haml
deleted file mode 100644
index f03ecacc3..000000000
--- a/app/views/admin/suspensions/new.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-- content_for :page_title do
-  = t('admin.suspensions.title', acct: @account.acct)
-
-= simple_form_for @suspension, url: admin_account_suspension_path(@account.id), method: :post do |f|
-  %p.hint= t('admin.suspensions.warning_html')
-
-  .fields-group
-    %ul
-      %li.negative-hint
-        = number_to_human @account.statuses_count, strip_insignificant_zeros: true
-        = t('accounts.posts', count: @account.statuses_count)
-      %li.negative-hint
-        = number_to_human @account.following_count, strip_insignificant_zeros: true
-        = t('accounts.following', count: @account.following_count)
-      %li.negative-hint
-        = number_to_human @account.followers_count, strip_insignificant_zeros: true
-        = t('accounts.followers', count: @account.followers_count)
-
-  %p.hint= t('admin.suspensions.hint_html', value: content_tag(:code, @account.acct))
-
-  = f.input :acct
-  = f.input_field :report_id, as: :hidden
-
-  .actions
-    = f.button :button, t('admin.suspensions.proceed'), type: :submit, class: 'negative'
diff --git a/app/views/admin/warning_presets/edit.html.haml b/app/views/admin/warning_presets/edit.html.haml
new file mode 100644
index 000000000..9522746cd
--- /dev/null
+++ b/app/views/admin/warning_presets/edit.html.haml
@@ -0,0 +1,11 @@
+- content_for :page_title do
+  = t('admin.warning_presets.edit_preset')
+
+= simple_form_for @warning_preset, url: admin_warning_preset_path(@warning_preset) do |f|
+  = render 'shared/error_messages', object: @warning_preset
+
+  .fields-group
+    = f.input :text, wrapper: :with_block_label
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/warning_presets/index.html.haml b/app/views/admin/warning_presets/index.html.haml
new file mode 100644
index 000000000..45913ef73
--- /dev/null
+++ b/app/views/admin/warning_presets/index.html.haml
@@ -0,0 +1,30 @@
+- content_for :page_title do
+  = t('admin.warning_presets.title')
+
+- if can? :create, :account_warning_preset
+  = simple_form_for @warning_preset, url: admin_warning_presets_path do |f|
+    = render 'shared/error_messages', object: @warning_preset
+
+    .fields-group
+      = f.input :text, wrapper: :with_block_label
+
+    .actions
+      = f.button :button, t('admin.warning_presets.add_new'), type: :submit
+
+  %hr.spacer/
+
+- unless @warning_presets.empty?
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('simple_form.labels.account_warning_preset.text')
+          %th
+      %tbody
+        - @warning_presets.each do |preset|
+          %tr
+            %td
+              = Formatter.instance.linkify(preset.text)
+            %td
+              = table_link_to 'pencil', t('admin.warning_presets.edit'), edit_admin_warning_preset_path(preset)
+              = table_link_to 'trash', t('admin.warning_presets.delete'), admin_warning_preset_path(preset), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
new file mode 100644
index 000000000..c5e1f5a28
--- /dev/null
+++ b/app/views/user_mailer/warning.html.haml
@@ -0,0 +1,63 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_warning.png'), alt: ''
+
+                              %h1= t "user_mailer.warning.title.#{@warning.action}"
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              - unless @warning.none_action?
+                                %p= t "user_mailer.warning.explanation.#{@warning.action}"
+
+                              - unless @warning.text.blank?
+                                = Formatter.instance.linkify(@warning.text)
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to about_more_url do
+                                    %span= t 'user_mailer.warning.review_server_policies'
diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb
new file mode 100644
index 000000000..b4f2402cb
--- /dev/null
+++ b/app/views/user_mailer/warning.text.erb
@@ -0,0 +1,9 @@
+<%= t "user_mailer.warning.title.#{@warning.action}" %>
+
+===
+
+<% unless @warning.none_action? %>
+<%= t "user_mailer.warning.explanation.#{@warning.action}" %>
+
+<% end %>
+<%= @warning.text %>
diff --git a/app/views/user_mailer/welcome.text.erb b/app/views/user_mailer/welcome.text.erb
index 5bd0cab2a..845458c32 100644
--- a/app/views/user_mailer/welcome.text.erb
+++ b/app/views/user_mailer/welcome.text.erb
@@ -2,7 +2,7 @@
 
 ===
 
-<%= t 'user_mailer.welcome.full_handle' %> (<%= "@#{@resource.account.username}@#{@instance}" %>)
+<%= t 'user_mailer.welcome.full_handle' %> (<%= "@#{@resource.account.local_username_and_domain}" %>)
 <%= t 'user_mailer.welcome.full_handle_hint', instance: @instance %>
 
 ---