diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index e7f56e243bb..e0ae71b9f2f 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -28,7 +28,7 @@ module Admin
@deletion_request = @account.deletion_request
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest
- @warnings = @account.strikes.custom.latest
+ @warnings = @account.strikes.includes(:target_account, :account, :appeal).latest
@domain_block = DomainBlock.rule_for(@account.domain)
end
@@ -146,7 +146,7 @@ module Admin
end
def filter_params
- params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
+ params.slice(:page, *AccountFilter::KEYS).permit(:page, *AccountFilter::KEYS)
end
def form_account_batch_params
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index f0a93541106..e376baab22a 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -8,6 +8,7 @@ module Admin
@pending_users_count = User.pending.count
@pending_reports_count = Report.unresolved.count
@pending_tags_count = Tag.pending_review.count
+ @pending_appeals_count = Appeal.pending.count
end
private
diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb
new file mode 100644
index 00000000000..32e5e2f6fd8
--- /dev/null
+++ b/app/controllers/admin/disputes/appeals_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class Admin::Disputes::AppealsController < Admin::BaseController
+ before_action :set_appeal, except: :index
+
+ def index
+ authorize :appeal, :index?
+
+ @appeals = filtered_appeals.page(params[:page])
+ end
+
+ def approve
+ authorize @appeal, :approve?
+ log_action :approve, @appeal
+ ApproveAppealService.new.call(@appeal, current_account)
+ redirect_to disputes_strike_path(@appeal.strike)
+ end
+
+ def reject
+ authorize @appeal, :approve?
+ log_action :reject, @appeal
+ @appeal.reject!(current_account)
+ UserMailer.appeal_rejected(@appeal.account.user, @appeal)
+ redirect_to disputes_strike_path(@appeal.strike)
+ end
+
+ private
+
+ def filtered_appeals
+ Admin::AppealFilter.new(filter_params.with_defaults(status: 'pending')).results.includes(strike: :account)
+ end
+
+ def filter_params
+ params.slice(:page, *Admin::AppealFilter::KEYS).permit(:page, *Admin::AppealFilter::KEYS)
+ end
+
+ def set_appeal
+ @appeal = Appeal.find(params[:id])
+ end
+end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index f37e906fded..3b025838b06 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -9,6 +9,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
+ before_action :set_strikes, only: [:edit, :update]
before_action :set_instance_presenter, only: [:new, :create, :update]
before_action :set_body_classes, only: [:new, :create, :edit, :update]
before_action :require_not_suspended!, only: [:update]
@@ -111,8 +112,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def set_invite
- invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
- @invite = invite&.valid_for_use? ? invite : nil
+ @invite = begin
+ invite = Invite.find_by(code: invite_code) if invite_code.present?
+ invite if invite&.valid_for_use?
+ end
end
def determine_layout
@@ -123,6 +126,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
@sessions = current_user.session_activations
end
+ def set_strikes
+ @strikes = current_account.strikes.active.latest
+ end
+
def require_not_suspended!
forbidden if current_account.suspended?
end
diff --git a/app/controllers/disputes/appeals_controller.rb b/app/controllers/disputes/appeals_controller.rb
new file mode 100644
index 00000000000..15367c87920
--- /dev/null
+++ b/app/controllers/disputes/appeals_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Disputes::AppealsController < Disputes::BaseController
+ before_action :set_strike
+
+ def create
+ authorize @strike, :appeal?
+
+ @appeal = AppealService.new.call(@strike, appeal_params[:text])
+
+ redirect_to disputes_strike_path(@strike), notice: I18n.t('disputes.strikes.appealed_msg')
+ rescue ActiveRecord::RecordInvalid
+ render template: 'disputes/strikes/show'
+ end
+
+ private
+
+ def set_strike
+ @strike = current_account.strikes.find(params[:strike_id])
+ end
+
+ def appeal_params
+ params.require(:appeal).permit(:text)
+ end
+end
diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb
new file mode 100644
index 00000000000..865146b5cc4
--- /dev/null
+++ b/app/controllers/disputes/base_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Disputes::BaseController < ApplicationController
+ include Authorization
+
+ layout 'admin'
+
+ skip_before_action :require_functional!
+
+ before_action :set_body_classes
+ before_action :authenticate_user!
+
+ private
+
+ def set_body_classes
+ @body_classes = 'admin'
+ end
+end
diff --git a/app/controllers/disputes/strikes_controller.rb b/app/controllers/disputes/strikes_controller.rb
new file mode 100644
index 00000000000..d41c5c727e5
--- /dev/null
+++ b/app/controllers/disputes/strikes_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Disputes::StrikesController < Disputes::BaseController
+ before_action :set_strike
+
+ def show
+ authorize @strike, :show?
+
+ @appeal = @strike.appeal || @strike.build_appeal
+ end
+
+ private
+
+ def set_strike
+ @strike = AccountWarning.find(params[:id])
+ end
+end
diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb
index 40b2a528922..2f08538ca68 100644
--- a/app/helpers/admin/account_moderation_notes_helper.rb
+++ b/app/helpers/admin/account_moderation_notes_helper.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
module Admin::AccountModerationNotesHelper
- def admin_account_link_to(account)
+ def admin_account_link_to(account, path: nil)
return if account.nil?
- link_to admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
+ link_to path || admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
safe_join([
image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'),
content_tag(:span, account.acct, class: 'username'),
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index f3aa4be4f23..47eeeaac3a8 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -33,6 +33,8 @@ module Admin::ActionLogsHelper
"#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
when 'Instance'
record.domain
+ when 'Appeal'
+ link_to record.account.acct, disputes_strike_path(record.strike)
end
end
diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb
new file mode 100644
index 00000000000..d16e3dd1219
--- /dev/null
+++ b/app/helpers/admin/trends/statuses_helper.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Admin::Trends::StatusesHelper
+ def one_line_preview(status)
+ text = begin
+ if status.local?
+ status.text.split("\n").first
+ else
+ Nokogiri::HTML(status.text).css('html > body > *').first&.text
+ end
+ end
+
+ return '' if text.blank?
+
+ html = Formatter.instance.send(:encode, text)
+ html = Formatter.instance.send(:encode_custom_emojis, html, status.emojis, prefers_autoplay?)
+
+ html.html_safe # rubocop:disable Rails/OutputSafety
+ end
+end
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 546de16400e..f5741bd5029 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -578,12 +578,16 @@ body,
}
.log-entry {
+ display: block;
line-height: 20px;
padding: 15px;
padding-left: 15px * 2 + 40px;
background: $ui-base-color;
border-bottom: 1px solid darken($ui-base-color, 8%);
position: relative;
+ text-decoration: none;
+ color: $darker-text-color;
+ font-size: 14px;
&:first-child {
border-top-left-radius: 4px;
@@ -596,15 +600,12 @@ body,
border-bottom: 0;
}
- &:hover {
+ &:hover,
+ &:focus,
+ &:active {
background: lighten($ui-base-color, 4%);
}
- &__header {
- color: $darker-text-color;
- font-size: 14px;
- }
-
&__avatar {
position: absolute;
left: 15px;
@@ -640,6 +641,18 @@ body,
text-decoration: underline;
}
}
+
+ &--inactive {
+ .log-entry__title {
+ text-decoration: line-through;
+ }
+
+ a,
+ .username,
+ .target {
+ color: $darker-text-color;
+ }
+ }
}
a.name-tag,
@@ -1175,6 +1188,17 @@ a.sparkline {
font-weight: 600;
padding: 4px 0;
}
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: none;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: underline;
+ }
+ }
}
&--horizontal {
@@ -1451,3 +1475,56 @@ a.sparkline {
}
}
}
+
+.strike-card {
+ padding: 15px;
+ border-radius: 4px;
+ background: $ui-base-color;
+ font-size: 15px;
+ line-height: 20px;
+ word-wrap: break-word;
+ font-weight: 400;
+ color: $primary-text-color;
+
+ p {
+ margin-bottom: 20px;
+ unicode-bidi: plaintext;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ &__statuses-list {
+ border-radius: 4px;
+ border: 1px solid darken($ui-base-color, 8%);
+ font-size: 13px;
+ line-height: 18px;
+ overflow: hidden;
+
+ &__item {
+ padding: 16px;
+ background: lighten($ui-base-color, 2%);
+ border-bottom: 1px solid darken($ui-base-color, 8%);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &__meta {
+ color: $darker-text-color;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+}
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index b23bd1296fb..a9d00c000dd 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -15,6 +15,16 @@ class AdminMailer < ApplicationMailer
end
end
+ def new_appeal(recipient, appeal)
+ @appeal = appeal
+ @me = recipient
+ @instance = Rails.configuration.x.local_domain
+
+ locale_for_account(@me) do
+ mail to: @me.user_email, subject: I18n.t('admin_mailer.new_appeal.subject', instance: @instance, username: @appeal.account.username)
+ end
+ end
+
def new_pending_account(recipient, user)
@account = user.account
@me = recipient
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 5221a48928e..583c948b0eb 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -173,6 +173,26 @@ class UserMailer < Devise::Mailer
end
end
+ def appeal_approved(user, appeal)
+ @resource = user
+ @instance = Rails.configuration.x.local_domain
+ @appeal = appeal
+
+ I18n.with_locale(@resource.locale || I18n.default_locale) do
+ mail to: @resource.email, subject: I18n.t('user_mailer.appeal_approved.subject', date: l(@appeal.created_at))
+ end
+ end
+
+ def appeal_rejected(user, appeal)
+ @resource = user
+ @instance = Rails.configuration.x.local_domain
+ @appeal = appeal
+
+ I18n.with_locale(@resource.locale || I18n.default_locale) do
+ mail to: @resource.email, subject: I18n.t('user_mailer.appeal_rejected.subject', date: l(@appeal.created_at))
+ end
+ end
+
def sign_in_token(user, remote_ip, user_agent, timestamp)
@resource = user
@instance = Rails.configuration.x.local_domain
diff --git a/app/models/account.rb b/app/models/account.rb
index 771cc0b1ba8..2ad45feda09 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -270,6 +270,10 @@ class Account < ApplicationRecord
true
end
+ def previous_strikes_count
+ strikes.where(overruled_at: nil).count
+ end
+
def keypair
@keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
end
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index 86b7f5f4174..9da1522dd13 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -24,6 +24,8 @@ class AccountFilter
scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil)
params.each do |key, value|
+ next if key.to_s == 'page'
+
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
end
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
index fc0d988fdc0..05d01942df0 100644
--- a/app/models/account_warning.rb
+++ b/app/models/account_warning.rb
@@ -12,6 +12,7 @@
# updated_at :datetime not null
# report_id :bigint(8)
# status_ids :string is an Array
+# overruled_at :datetime
#
class AccountWarning < ApplicationRecord
@@ -28,12 +29,17 @@ class AccountWarning < ApplicationRecord
belongs_to :target_account, class_name: 'Account', inverse_of: :strikes
belongs_to :report, optional: true
- has_one :appeal, dependent: :destroy
+ has_one :appeal, dependent: :destroy, inverse_of: :strike
scope :latest, -> { order(id: :desc) }
scope :custom, -> { where.not(text: '') }
+ scope :active, -> { where(overruled_at: nil).or(where('account_warnings.overruled_at >= ?', 30.days.ago)) }
def statuses
Status.with_discarded.where(id: status_ids || [])
end
+
+ def overruled?
+ overruled_at.present?
+ end
end
diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb
index 12136223be5..0f2f712a255 100644
--- a/app/models/admin/action_log_filter.rb
+++ b/app/models/admin/action_log_filter.rb
@@ -8,6 +8,8 @@ class Admin::ActionLogFilter
).freeze
ACTION_TYPE_MAP = {
+ approve_appeal: { target_type: 'Appeal', action: 'approve' }.freeze,
+ reject_appeal: { target_type: 'Appeal', action: 'reject' }.freeze,
assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
confirm_user: { target_type: 'User', action: 'confirm' }.freeze,
diff --git a/app/models/admin/appeal_filter.rb b/app/models/admin/appeal_filter.rb
new file mode 100644
index 00000000000..b163d2e5686
--- /dev/null
+++ b/app/models/admin/appeal_filter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class Admin::AppealFilter
+ KEYS = %i(
+ status
+ ).freeze
+
+ attr_reader :params
+
+ def initialize(params)
+ @params = params
+ end
+
+ def results
+ scope = Appeal.order(id: :desc)
+
+ params.each do |key, value|
+ next if %w(page).include?(key.to_s)
+
+ scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+ end
+
+ scope
+ end
+
+ private
+
+ def scope_for(key, value)
+ case key.to_s
+ when 'status'
+ status_scope(value)
+ else
+ raise "Unknown filter: #{key}"
+ end
+ end
+
+ def status_scope(value)
+ case value
+ when 'approved'
+ Appeal.approved
+ when 'rejected'
+ Appeal.rejected
+ when 'pending'
+ Appeal.pending
+ else
+ raise "Unknown status: #{value}"
+ end
+ end
+end
diff --git a/app/models/appeal.rb b/app/models/appeal.rb
new file mode 100644
index 00000000000..46f35ae375e
--- /dev/null
+++ b/app/models/appeal.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: appeals
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8) not null
+# account_warning_id :bigint(8) not null
+# text :text default(""), not null
+# approved_at :datetime
+# approved_by_account_id :bigint(8)
+# rejected_at :datetime
+# rejected_by_account_id :bigint(8)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+class Appeal < ApplicationRecord
+ belongs_to :account
+ belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id'
+ belongs_to :approved_by_account, class_name: 'Account', optional: true
+ belongs_to :rejected_by_account, class_name: 'Account', optional: true
+
+ validates :text, presence: true, length: { maximum: 2_000 }
+ validates :account_warning_id, uniqueness: true
+
+ validate :validate_time_frame, on: :create
+
+ scope :approved, -> { where.not(approved_at: nil) }
+ scope :rejected, -> { where.not(rejected_at: nil) }
+ scope :pending, -> { where(approved_at: nil, rejected_at: nil) }
+
+ def pending?
+ !approved? && !rejected?
+ end
+
+ def approved?
+ approved_at.present?
+ end
+
+ def rejected?
+ rejected_at.present?
+ end
+
+ def approve!(current_account)
+ update!(approved_at: Time.now.utc, approved_by_account: current_account)
+ end
+
+ def reject!(current_account)
+ update!(rejected_at: Time.now.utc, rejected_by_account: current_account)
+ end
+
+ private
+
+ def validate_time_frame
+ errors.add(:base, I18n.t('strikes.errors.too_late')) if Time.now.utc > (strike.created_at + 20.days)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index fd1d7049a9c..517254a910d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -265,6 +265,10 @@ class User < ApplicationRecord
settings.notification_emails['pending_account']
end
+ def allows_appeal_emails?
+ settings.notification_emails['appeal']
+ end
+
def allows_trending_tag_emails?
settings.notification_emails['trending_tag']
end
diff --git a/app/policies/account_warning_policy.rb b/app/policies/account_warning_policy.rb
new file mode 100644
index 00000000000..6b92da475bd
--- /dev/null
+++ b/app/policies/account_warning_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AccountWarningPolicy < ApplicationPolicy
+ def show?
+ target? || staff?
+ end
+
+ def appeal?
+ target?
+ end
+
+ private
+
+ def target?
+ record.target_account_id == current_account&.id
+ end
+end
diff --git a/app/policies/appeal_policy.rb b/app/policies/appeal_policy.rb
new file mode 100644
index 00000000000..a25187172a4
--- /dev/null
+++ b/app/policies/appeal_policy.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AppealPolicy < ApplicationPolicy
+ def index?
+ staff?
+ end
+
+ def approve?
+ record.pending? && staff?
+ end
+
+ alias reject? approve?
+end
diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb
new file mode 100644
index 00000000000..1397c50f5fa
--- /dev/null
+++ b/app/services/appeal_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class AppealService < BaseService
+ def call(strike, text)
+ @strike = strike
+ @text = text
+
+ create_appeal!
+ notify_staff!
+
+ @appeal
+ end
+
+ private
+
+ def create_appeal!
+ @appeal = @strike.create_appeal!(
+ text: @text,
+ account: @strike.target_account
+ )
+ end
+
+ def notify_staff!
+ User.staff.includes(:account).each do |u|
+ AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails?
+ end
+ end
+end
diff --git a/app/services/approve_appeal_service.rb b/app/services/approve_appeal_service.rb
new file mode 100644
index 00000000000..f76bf8943e5
--- /dev/null
+++ b/app/services/approve_appeal_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class ApproveAppealService < BaseService
+ def call(appeal, current_account)
+ @appeal = appeal
+ @strike = appeal.strike
+ @current_account = current_account
+
+ ApplicationRecord.transaction do
+ undo_strike_action!
+ mark_strike_as_appealed!
+ end
+
+ queue_workers!
+ notify_target_account!
+ end
+
+ private
+
+ def target_account
+ @strike.target_account
+ end
+
+ def undo_strike_action!
+ case @strike.action
+ when 'disable'
+ undo_disable!
+ when 'delete_statuses'
+ undo_delete_statuses!
+ when 'sensitive'
+ undo_sensitive!
+ when 'silence'
+ undo_silence!
+ when 'suspend'
+ undo_suspend!
+ end
+ end
+
+ def mark_strike_as_appealed!
+ @appeal.approve!(@current_account)
+ @strike.touch(:overruled_at)
+ end
+
+ def undo_disable!
+ target_account.user.enable!
+ end
+
+ def undo_delete_statuses!
+ # Cannot be undone
+ end
+
+ def undo_sensitive!
+ target_account.unsensitize!
+ end
+
+ def undo_silence!
+ target_account.unsilence!
+ end
+
+ def undo_suspend!
+ target_account.unsuspend!
+ end
+
+ def queue_workers!
+ case @strike.action
+ when 'suspend'
+ Admin::UnsuspensionWorker.perform_async(target_account.id)
+ end
+ end
+
+ def notify_target_account!
+ UserMailer.appeal_approved(target_account.user, @appeal).deliver_later
+ end
+end
diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
deleted file mode 100644
index 432fb79a6e5..00000000000
--- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.speech-bubble
- .speech-bubble__bubble
- = simple_format(h(account_moderation_note.content))
- .speech-bubble__owner
- = admin_account_link_to account_moderation_note.account
- %time.formatted{ datetime: account_moderation_note.created_at.iso8601 }= l account_moderation_note.created_at
- = table_link_to 'trash', t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
diff --git a/app/views/admin/account_warnings/_account_warning.html.haml b/app/views/admin/account_warnings/_account_warning.html.haml
index 8c9c9679ced..ef23c3b7764 100644
--- a/app/views/admin/account_warnings/_account_warning.html.haml
+++ b/app/views/admin/account_warnings/_account_warning.html.haml
@@ -1,6 +1,24 @@
-.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
+= link_to disputes_strike_path(account_warning), class: ['log-entry', account_warning.overruled? && 'log-entry--inactive'] do
+ .log-entry__header
+ .log-entry__avatar
+ = image_tag account_warning.target_account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
+ .log-entry__content
+ .log-entry__title
+ = t(account_warning.action, scope: 'admin.strikes.actions', name: content_tag(:span, account_warning.account.username, class: 'username'), target: content_tag(:span, account_warning.target_account.acct, class: 'target')).html_safe
+ .log-entry__timestamp
+ %time.formatted{ datetime: account_warning.created_at.iso8601 }
+ = l(account_warning.created_at)
+
+ - if account_warning.report_id.present?
+ ·
+ = t('admin.reports.title', id: account_warning.report_id)
+
+ - if account_warning.overruled?
+ ·
+ %span.positive-hint= t('admin.strikes.appeal_approved')
+ - elsif account_warning.appeal&.pending?
+ ·
+ %span.warning-hint= t('admin.strikes.appeal_pending')
+ - elsif account_warning.appeal&.rejected?
+ ·
+ %span.negative-hint= t('admin.strikes.appeal_rejected')
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index f3853d629e1..9a1f07a0665 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -246,18 +246,29 @@
%hr.spacer/
- unless @warnings.empty?
- = render @warnings
+
+ %h3= t 'admin.accounts.previous_strikes'
+
+ %p= t('admin.accounts.previous_strikes_description_html', count: @account.previous_strikes_count)
+
+ .account-strikes
+ = render @warnings
%hr.spacer/
- = render @moderation_notes
+ %h3= t 'admin.reports.notes.title'
+
+ %p= t 'admin.reports.notes_description_html'
+
+ .report-notes
+ = render partial: 'admin/report_notes/report_note', collection: @moderation_notes
= simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
- = render 'shared/error_messages', object: @account_moderation_note
-
- = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
= f.hidden_field :target_account_id
+ .field-group
+ = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
+
.actions
= f.button :button, t('admin.account_moderation_notes.create'), type: :submit
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 4b581f5ea63..59b75e0e1da 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -46,6 +46,9 @@
%span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
= fa_icon 'chevron-right fw'
+ = link_to admin_disputes_appeals_path(status: 'pending'), class: 'dashboard__quick-access' do
+ %span= t('admin.dashboard.pending_appeals_html', count: @pending_appeals_count)
+ = fa_icon 'chevron-right fw'
.dashboard__item
= react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
diff --git a/app/views/admin/disputes/appeals/_appeal.html.haml b/app/views/admin/disputes/appeals/_appeal.html.haml
new file mode 100644
index 00000000000..02b8777e13c
--- /dev/null
+++ b/app/views/admin/disputes/appeals/_appeal.html.haml
@@ -0,0 +1,21 @@
+= link_to disputes_strike_path(appeal.strike), class: ['log-entry', appeal.approved? && 'log-entry--inactive'] do
+ .log-entry__header
+ .log-entry__avatar
+ = image_tag appeal.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
+ .log-entry__content
+ .log-entry__title
+ = t(appeal.strike.action, scope: 'admin.strikes.actions', name: content_tag(:span, appeal.strike.account.username, class: 'username'), target: content_tag(:span, appeal.account.acct, class: 'target')).html_safe
+ .log-entry__timestamp
+ %time.formatted{ datetime: appeal.strike.created_at.iso8601 }
+ = l(appeal.strike.created_at)
+
+ - if appeal.strike.report_id.present?
+ ·
+ = t('admin.reports.title', id: appeal.strike.report_id)
+ ·
+ - if appeal.approved?
+ %span.positive-hint= t('admin.strikes.appeal_approved')
+ - elsif appeal.rejected?
+ %span.negative-hint= t('admin.strikes.appeal_rejected')
+ - else
+ %span.warning-hint= t('admin.strikes.appeal_pending')
diff --git a/app/views/admin/disputes/appeals/index.html.haml b/app/views/admin/disputes/appeals/index.html.haml
new file mode 100644
index 00000000000..dd6a6f403f1
--- /dev/null
+++ b/app/views/admin/disputes/appeals/index.html.haml
@@ -0,0 +1,22 @@
+- content_for :page_title do
+ = t('admin.disputes.appeals.title')
+
+- content_for :header_tags do
+ = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+.filters
+ .filter-subset
+ %strong= t('admin.tags.review')
+ %ul
+ %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Appeal.pending.count})"], ' '), status: 'pending'
+ %li= filter_link_to t('admin.trends.approved'), status: 'approved'
+ %li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
+
+- if @appeals.empty?
+ %div.muted-hint.center-text
+ = t 'admin.disputes.appeals.empty'
+- else
+ .announcements-list
+ = render partial: 'appeal', collection: @appeals
+
+= paginate @appeals
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 428b6cf59c6..f9d57c2ae34 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -3,7 +3,7 @@
.report-notes__item__header
%span.username
- = link_to display_name(report_note.account), admin_account_path(report_note.account_id)
+ = link_to report_note.account.username, admin_account_path(report_note.account_id)
%time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
- if report_note.created_at.today?
= t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time))
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 02c46e3840f..e53c180e503 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -57,7 +57,7 @@
.report-header__details__item__header
%strong= t('admin.accounts.strikes')
.report-header__details__item__content
- = @report.target_account.strikes.count
+ = @report.target_account.previous_strikes_count
.report-header__details
.report-header__details__item
diff --git a/app/views/admin_mailer/new_appeal.text.erb b/app/views/admin_mailer/new_appeal.text.erb
new file mode 100644
index 00000000000..db4529eb7d6
--- /dev/null
+++ b/app/views/admin_mailer/new_appeal.text.erb
@@ -0,0 +1,9 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_appeal.body', target: @appeal.account.username, action_taken_by: @appeal.strike.account.username, date: l(@appeal.strike.created_at), type: t(@appeal.strike.action, scope: 'admin_mailer.new_appeal.actions')) %>
+
+> <%= raw word_wrap(@appeal.text, break_sequence: "\n> ") %>
+
+<%= raw t('admin_mailer.new_appeal.next_steps') %>
+
+<%= raw t('application_mailer.view')%> <%= disputes_strike_url(@appeal.strike) %>
diff --git a/app/views/auth/registrations/_account_warning.html.haml b/app/views/auth/registrations/_account_warning.html.haml
new file mode 100644
index 00000000000..40e7e12968b
--- /dev/null
+++ b/app/views/auth/registrations/_account_warning.html.haml
@@ -0,0 +1,20 @@
+= link_to disputes_strike_path(account_warning), class: 'log-entry' do
+ .log-entry__header
+ .log-entry__avatar
+ .indicator-icon{ class: account_warning.overruled? ? 'success' : 'failure' }
+ = fa_icon 'warning'
+ .log-entry__content
+ .log-entry__title
+ = t('disputes.strikes.title', action: t(account_warning.action, scope: 'disputes.strikes.title_actions'), date: l(account_warning.created_at.to_date))
+ .log-entry__timestamp
+ %time.formatted{ datetime: account_warning.created_at.iso8601 }= l(account_warning.created_at)
+
+ - if account_warning.overruled?
+ ·
+ %span.positive-hint= t('disputes.strikes.your_appeal_approved')
+ - elsif account_warning.appeal&.pending?
+ ·
+ %span.warning-hint= t('disputes.strikes.your_appeal_pending')
+ - elsif account_warning.appeal&.rejected?
+ ·
+ %span.negative-hint= t('disputes.strikes.your_appeal_rejected')
diff --git a/app/views/auth/registrations/_status.html.haml b/app/views/auth/registrations/_status.html.haml
index 47112dae07c..3546510b21d 100644
--- a/app/views/auth/registrations/_status.html.haml
+++ b/app/views/auth/registrations/_status.html.haml
@@ -1,22 +1,17 @@
+- if !@user.confirmed?
+ .flash-message.warning
+ = t('auth.status.confirming')
+ = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
+- elsif !@user.approved?
+ .flash-message.warning
+ = t('auth.status.pending')
+- elsif @user.account.moved_to_account_id.present?
+ .flash-message.warning
+ = t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct)
+ = link_to t('migrations.cancel'), settings_migration_path
+
%h3= t('auth.status.account_status')
-.simple_form
- %p.hint
- - if @user.account.suspended?
- %span.negative-hint= t('user_mailer.warning.explanation.suspend')
- - elsif @user.disabled?
- %span.negative-hint= t('user_mailer.warning.explanation.disable')
- - elsif @user.account.silenced?
- %span.warning-hint= t('user_mailer.warning.explanation.silence')
- - elsif !@user.confirmed?
- %span.warning-hint= t('auth.status.confirming')
- = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
- - elsif !@user.approved?
- %span.warning-hint= t('auth.status.pending')
- - elsif @user.account.moved_to_account_id.present?
- %span.positive-hint= t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct)
- = link_to t('migrations.cancel'), settings_migration_path
- - else
- %span.positive-hint= t('auth.status.functional')
+= render partial: 'account_warning', collection: @strikes
%hr.spacer/
diff --git a/app/views/disputes/strikes/show.html.haml b/app/views/disputes/strikes/show.html.haml
new file mode 100644
index 00000000000..3dcb19016e6
--- /dev/null
+++ b/app/views/disputes/strikes/show.html.haml
@@ -0,0 +1,127 @@
+- content_for :page_title do
+ = t('disputes.strikes.title', action: t(@strike.action, scope: 'disputes.strikes.title_actions'), date: l(@strike.created_at.to_date))
+
+- content_for :heading_actions do
+ - if @appeal.persisted?
+ = link_to t('admin.accounts.approve'), approve_admin_disputes_appeal_path(@appeal), method: :post, class: 'button' if can?(:approve, @appeal)
+ = link_to t('admin.accounts.reject'), reject_admin_disputes_appeal_path(@appeal), method: :post, class: 'button button--destructive' if can?(:reject, @appeal)
+
+- if @strike.overruled?
+ %p.hint
+ %span.positive-hint
+ = fa_icon 'check'
+ = ' '
+ = t 'disputes.strikes.appeal_approved'
+- elsif @appeal.persisted? && @appeal.rejected?
+ %p.hint
+ %span.negative-hint
+ = fa_icon 'times'
+ = ' '
+ = t 'disputes.strikes.appeal_rejected'
+
+.report-header
+ .report-header__card
+ .strike-card
+ - unless @strike.none_action?
+ %p= t "user_mailer.warning.explanation.#{@strike.action}"
+
+ - unless @strike.text.blank?
+ = Formatter.instance.linkify(@strike.text)
+
+ - if @strike.report && !@strike.report.other?
+ %p
+ %strong= t('user_mailer.warning.reason')
+ = t("user_mailer.warning.categories.#{@strike.report.category}")
+
+ - if @strike.report.violation? && @strike.report.rule_ids.present?
+ %ul.rules-list
+ - @strike.report.rules.each do |rule|
+ %li= rule.text
+
+ - if @strike.status_ids.present? && !@strike.status_ids.empty?
+ %p
+ %strong= t('user_mailer.warning.statuses')
+
+ .strike-card__statuses-list
+ - status_map = @strike.statuses.includes(:application, :media_attachments).index_by(&:id)
+
+ - @strike.status_ids.each do |status_id|
+ .strike-card__statuses-list__item
+ - if (status = status_map[status_id.to_i])
+ .one-liner
+ = link_to short_account_status_url(@strike.target_account, status_id), class: 'emojify' do
+ = one_line_preview(status)
+
+ - status.media_attachments.each do |media_attachment|
+ %abbr{ title: media_attachment.description }
+ = fa_icon 'link'
+ = media_attachment.file_file_name
+ .strike-card__statuses-list__item__meta
+ %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+ ·
+ = status.application.name
+ - else
+ .one-liner= t('disputes.strikes.status', id: status_id)
+ .strike-card__statuses-list__item__meta
+ = t('disputes.strikes.status_removed')
+
+ .report-header__details
+ .report-header__details__item
+ .report-header__details__item__header
+ %strong= t('disputes.strikes.created_at')
+ .report-header__details__item__content
+ %time.formatted{ datetime: @strike.created_at.iso8601, title: l(@strike.created_at) }= l(@strike.created_at)
+ .report-header__details__item
+ .report-header__details__item__header
+ %strong= t('disputes.strikes.recipient')
+ .report-header__details__item__content
+ = admin_account_link_to @strike.target_account, path: can?(:show, @strike.target_account) ? admin_account_path(@strike.target_account_id) : ActivityPub::TagManager.instance.url_for(@strike.target_account)
+ .report-header__details__item
+ .report-header__details__item__header
+ %strong= t('disputes.strikes.action_taken')
+ .report-header__details__item__content
+ - if @strike.overruled?
+ %del= t(@strike.action, scope: 'user_mailer.warning.title')
+ - else
+ = t(@strike.action, scope: 'user_mailer.warning.title')
+ - if @strike.report && can?(:show, @strike.report)
+ .report-header__details__item
+ .report-header__details__item__header
+ %strong= t('disputes.strikes.associated_report')
+ .report-header__details__item__content
+ = link_to t('admin.reports.report', id: @strike.report.id), admin_report_path(@strike.report)
+ - if @appeal.persisted?
+ .report-header__details__item
+ .report-header__details__item__header
+ %strong= t('disputes.strikes.appeal_submitted_at')
+ .report-header__details__item__content
+ %time.formatted{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }= l(@appeal.created_at)
+%hr.spacer/
+
+- if @appeal.persisted?
+ %h3= t('disputes.strikes.appeal')
+
+ .report-notes
+ .report-notes__item
+ = image_tag @appeal.account.avatar.url, class: 'report-notes__item__avatar'
+
+ .report-notes__item__header
+ %span.username
+ = link_to @appeal.account.username, can?(:show, @appeal.account) ? admin_account_path(@appeal.account_id) : short_account_url(@appeal.account)
+ %time{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }
+ - if @appeal.created_at.today?
+ = t('admin.report_notes.today_at', time: l(@appeal.created_at, format: :time))
+ - else
+ = l @appeal.created_at.to_date
+
+ .report-notes__item__content
+ = simple_format(h(@appeal.text))
+- elsif can?(:appeal, @strike)
+ %h3= t('disputes.strikes.appeals.submit')
+
+ = simple_form_for(@appeal, url: disputes_strike_appeal_path(@strike)) do |f|
+ .fields-group
+ = f.input :text, wrapper: :with_label, input_html: { maxlength: 500 }
+
+ .actions
+ = f.button :button, t('disputes.strikes.appeals.submit'), type: :submit
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index d7cc1ed5d16..223e5d7407a 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -21,6 +21,7 @@
- if current_user.staff?
= ff.input :report, as: :boolean, wrapper: :with_label
+ = ff.input :appeal, as: :boolean, wrapper: :with_label
= ff.input :pending_account, as: :boolean, wrapper: :with_label
= ff.input :trending_tag, as: :boolean, wrapper: :with_label
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index cd5ed52af4a..1922f53ce32 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -49,7 +49,7 @@
%span.detailed-status__visibility-icon
= visibility_icon status
·
- - if status.application && @account.user&.setting_show_application
+ - if status.application && status.account.user&.setting_show_application
- if status.application.website.blank?
%strong.detailed-status__application= status.application.name
- else
diff --git a/app/views/user_mailer/appeal_approved.html.haml b/app/views/user_mailer/appeal_approved.html.haml
new file mode 100644
index 00000000000..962cab2e2cf
--- /dev/null
+++ b/app/views/user_mailer/appeal_approved.html.haml
@@ -0,0 +1,59 @@
+%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{ align: 'center', cellspacing: 0, cellpadding: 0 }
+ %tbody
+ %tr
+ %td
+ = image_tag full_pack_url('media/images/mailer/icon_done.png'), alt: ''
+
+ %h1= t 'user_mailer.appeal_approved.title'
+
+%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
+ %p= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at)
+
+%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 root_url do
+ %span= t 'user_mailer.appeal_approved.action'
diff --git a/app/views/user_mailer/appeal_approved.text.erb b/app/views/user_mailer/appeal_approved.text.erb
new file mode 100644
index 00000000000..290fa24c365
--- /dev/null
+++ b/app/views/user_mailer/appeal_approved.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.appeal_approved.title' %>
+
+===
+
+<%= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+
+=> <%= root_url %>
diff --git a/app/views/user_mailer/appeal_rejected.html.haml b/app/views/user_mailer/appeal_rejected.html.haml
new file mode 100644
index 00000000000..75cd9d023ba
--- /dev/null
+++ b/app/views/user_mailer/appeal_rejected.html.haml
@@ -0,0 +1,59 @@
+%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{ align: 'center', cellspacing: 0, cellpadding: 0 }
+ %tbody
+ %tr
+ %td
+ = image_tag full_pack_url('media/images/mailer/icon_warning.png'), alt: ''
+
+ %h1= t 'user_mailer.appeal_rejected.title'
+
+%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
+ %p= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at)
+
+%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 root_url do
+ %span= t 'user_mailer.appeal_approved.action'
diff --git a/app/views/user_mailer/appeal_rejected.text.erb b/app/views/user_mailer/appeal_rejected.text.erb
new file mode 100644
index 00000000000..f47a768181b
--- /dev/null
+++ b/app/views/user_mailer/appeal_rejected.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.appeal_rejected.title' %>
+
+===
+
+<%= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+
+=> <%= root_url %>
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index bda1fef6cfa..b308e18f7b0 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -77,8 +77,8 @@
%tbody
%tr
%td.button-primary
- = link_to about_more_url do
- %span= t 'user_mailer.warning.review_server_policies'
+ = link_to disputes_strike_url(@warning) do
+ %span= t 'user_mailer.warning.appeal'
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
@@ -95,4 +95,4 @@
%tbody
%tr
%td.column-cell.text-center
- %p= t 'user_mailer.warning.get_in_touch', instance: @instance
+ %p= t 'user_mailer.warning.appeal_description', instance: @instance
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 4245b719248..6ffe12ae06f 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -1,25 +1,5 @@
{
"ignored_warnings": [
- {
- "warning_type": "SQL Injection",
- "warning_code": 0,
- "fingerprint": "04dbbc249b989db2e0119bbb0f59c9818e12889d2b97c529cdc0b1526002ba4b",
- "check_name": "SQL",
- "message": "Possible SQL injection",
- "file": "app/models/report.rb",
- "line": 113,
- "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
- "code": "Admin::ActionLog.from(\"(#{[Admin::ActionLog.where(:target_type => \"Report\", :target_id => id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Account\", :target_id => target_account_id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)].map do\n \"(#{query.to_sql})\"\n end.join(\" UNION ALL \")}) AS admin_action_logs\")",
- "render_path": null,
- "location": {
- "type": "method",
- "class": "Report",
- "method": "history"
- },
- "user_input": "Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)",
- "confidence": "High",
- "note": ""
- },
{
"warning_type": "SQL Injection",
"warning_code": 0,
@@ -27,7 +7,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/status.rb",
- "line": 100,
+ "line": 104,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
"render_path": null,
@@ -107,7 +87,7 @@
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/admin/reports_controller.rb",
- "line": 78,
+ "line": 90,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:resolved, :account_id, :target_account_id)",
"render_path": null,
@@ -140,6 +120,36 @@
"confidence": "Medium",
"note": ""
},
+ {
+ "warning_type": "Cross-Site Scripting",
+ "warning_code": 2,
+ "fingerprint": "afad51718ae373b2f19d2513029fd2afccf58b9148e475934bc6a162ee33c352",
+ "check_name": "CrossSiteScripting",
+ "message": "Unescaped model attribute",
+ "file": "app/views/admin/disputes/appeals/_appeal.html.haml",
+ "line": 7,
+ "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
+ "code": "t((Unresolved Model).new.strike.action, :scope => \"admin.strikes.actions\", :name => content_tag(:span, (Unresolved Model).new.strike.account.username, :class => \"username\"), :target => content_tag(:span, (Unresolved Model).new.account.acct, :class => \"target\"))",
+ "render_path": [
+ {
+ "type": "template",
+ "name": "admin/disputes/appeals/index",
+ "line": 16,
+ "file": "app/views/admin/disputes/appeals/index.html.haml",
+ "rendered": {
+ "name": "admin/disputes/appeals/_appeal",
+ "file": "app/views/admin/disputes/appeals/_appeal.html.haml"
+ }
+ }
+ ],
+ "location": {
+ "type": "template",
+ "template": "admin/disputes/appeals/_appeal"
+ },
+ "user_input": "(Unresolved Model).new.strike",
+ "confidence": "Weak",
+ "note": ""
+ },
{
"warning_type": "Redirect",
"warning_code": 18,
@@ -194,7 +204,7 @@
{
"type": "template",
"name": "admin/trends/links/index",
- "line": 37,
+ "line": 39,
"file": "app/views/admin/trends/links/index.html.haml",
"rendered": {
"name": "admin/trends/links/_preview_card",
@@ -213,13 +223,13 @@
{
"warning_type": "Mass Assignment",
"warning_code": 105,
- "fingerprint": "e867661b2c9812bc8b75a5df12b28e2a53ab97015de0638b4e732fe442561b28",
+ "fingerprint": "f9de0ca4b04ae4b51b74d98db14dcbb6dae6809e627b58e711019cf9b4a47866",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/reports_controller.rb",
"line": 36,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
- "code": "params.permit(:account_id, :comment, :forward, :status_ids => ([]))",
+ "code": "params.permit(:account_id, :comment, :category, :forward, :status_ids => ([]), :rule_ids => ([]))",
"render_path": null,
"location": {
"type": "method",
@@ -231,6 +241,6 @@
"note": ""
}
],
- "updated": "2021-11-14 05:26:09 +0100",
- "brakeman_version": "5.1.2"
+ "updated": "2022-02-13 02:24:12 +0100",
+ "brakeman_version": "5.2.1"
}
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 1809f123ed8..05c64e18a76 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -94,7 +94,6 @@ en:
account_moderation_notes:
create: Leave note
created_msg: Moderation note successfully created!
- delete: Delete
destroyed_msg: Moderation note successfully destroyed!
accounts:
add_email_domain_block: Block e-mail domain
@@ -163,6 +162,11 @@ en:
not_subscribed: Not subscribed
pending: Pending review
perform_full_suspension: Suspend
+ previous_strikes: Previous strikes
+ previous_strikes_description_html:
+ one: This account has one strike.
+ other: This account has %{count} strikes.
+ zero: This account is in good standing.
promote: Promote
protocol: Protocol
public: Public
@@ -227,6 +231,7 @@ en:
whitelisted: Allowed for federation
action_logs:
action_types:
+ approve_appeal: Approve Appeal
approve_user: Approve User
assigned_to_self_report: Assign Report
change_email_user: Change E-mail for User
@@ -258,6 +263,7 @@ en:
enable_user: Enable User
memorialize_account: Memorialize Account
promote_user: Promote User
+ reject_appeal: Reject Appeal
reject_user: Reject User
remove_avatar_user: Remove Avatar
reopen_report: Reopen Report
@@ -276,6 +282,7 @@ en:
update_domain_block: Update Domain Block
update_status: Update Post
actions:
+ approve_appeal_html: "%{name} approved moderation decision appeal from %{target}"
approve_user_html: "%{name} approved sign-up from %{target}"
assigned_to_self_report_html: "%{name} assigned report %{target} to themselves"
change_email_user_html: "%{name} changed the e-mail address of user %{target}"
@@ -307,6 +314,7 @@ en:
enable_user_html: "%{name} enabled login for user %{target}"
memorialize_account_html: "%{name} turned %{target}'s account into a memoriam page"
promote_user_html: "%{name} promoted user %{target}"
+ reject_appeal_html: "%{name} rejected moderation decision appeal from %{target}"
reject_user_html: "%{name} rejected sign-up from %{target}"
remove_avatar_user_html: "%{name} removed %{target}'s avatar"
reopen_report_html: "%{name} reopened report %{target}"
@@ -385,6 +393,9 @@ en:
media_storage: Media storage
new_users: new users
opened_reports: reports opened
+ pending_appeals_html:
+ one: "1 pending appeal"
+ other: "%{count} pending appeals"
pending_reports_html:
one: "1 pending report"
other: "%{count} pending reports"
@@ -402,6 +413,10 @@ en:
top_languages: Top active languages
top_servers: Top active servers
website: Website
+ disputes:
+ appeals:
+ empty: No appeals found.
+ title: Appeals
domain_allows:
add_new: Allow federation with domain
created_msg: Domain has been successfully allowed for federation
@@ -720,6 +735,16 @@ en:
no_status_selected: No posts were changed as none were selected
title: Account posts
with_media: With media
+ strikes:
+ actions:
+ delete_statuses: "%{name} deleted %{target}'s posts"
+ disable: "%{name} froze %{target}'s account"
+ none: "%{name} sent a warning to %{target}"
+ sensitive: "%{name} marked %{target}'s account as sensitive"
+ silence: "%{name} limited %{target}'s account"
+ suspend: "%{name} suspended %{target}'s account"
+ appeal_approved: Appealed
+ appeal_pending: Appeal pending
system_checks:
database_schema_check:
message_html: There are pending database migrations. Please run them to ensure the application behaves as expected
@@ -781,6 +806,17 @@ en:
empty: You haven't defined any warning presets yet.
title: Manage warning presets
admin_mailer:
+ new_appeal:
+ actions:
+ delete_statuses: to delete their posts
+ disable: to freeze their account
+ none: a warning
+ sensitive: to mark their account as sensitive
+ silence: to limit their account
+ suspend: to suspend their account
+ body: "%{target} is appealing a moderation decision by %{action_taken_by} from %{date}, which was %{type}. They wrote:"
+ next_steps: You can approve the appeal to undo the moderation decision, or ignore it.
+ subject: "%{username} is appealing a moderation decision on %{instance}"
new_pending_account:
body: The details of the new account are below. You can approve or reject this application.
subject: New account up for review on %{instance} (%{username})
@@ -871,7 +907,6 @@ en:
status:
account_status: Account status
confirming: Waiting for e-mail confirmation to be completed.
- functional: Your account is fully operational.
pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved.
redirecting_to: Your account is inactive because it is currently redirecting to %{acct}.
too_fast: Form submitted too fast, try again.
@@ -937,6 +972,32 @@ en:
directory: Profile directory
explanation: Discover users based on their interests
explore_mastodon: Explore %{title}
+ disputes:
+ strikes:
+ action_taken: Action taken
+ appeal: Appeal
+ appeal_approved: This strike has been successfully appealed and is no longer valid
+ appeal_rejected: The appeal has been rejected
+ appeal_submitted_at: Appeal submitted
+ appealed_msg: Your appeal has been submitted. If it is approved, you will be notified.
+ appeals:
+ submit: Submit appeal
+ associated_report: Associated report
+ created_at: Dated
+ recipient: Addressed to
+ status: 'Post #%{id}'
+ status_removed: Post already removed from system
+ title: "%{action} from %{date}"
+ title_actions:
+ delete_statuses: Post removal
+ disable: Freezing of account
+ none: Warning
+ sensitive: Marking as sensitive of account
+ silence: Limitation of account
+ suspend: Suspension of account
+ your_appeal_approved: Your appeal has been approved
+ your_appeal_pending: You have submitted an appeal
+ your_appeal_rejected: Your appeal has been rejected
domain_validator:
invalid_domain: is not a valid domain name
errors:
@@ -1501,6 +1562,15 @@ en:
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe. For example, you may print them and store them with other important documents.
webauthn: Security keys
user_mailer:
+ appeal_approved:
+ action: Go to your account
+ explanation: The appeal of the strike against your account on %{strike_date} that you submitted on %{appeal_date} has been approved. Your account is once again in good standing.
+ subject: Your appeal from %{date} has been approved
+ title: Appeal approved
+ appeal_rejected:
+ explanation: The appeal of the strike against your account on %{strike_date} that you submitted on %{appeal_date} has been rejected.
+ subject: Your appeal from %{date} has been rejected
+ title: Appeal rejected
backup_ready:
explanation: You requested a full backup of your Mastodon account. It's now ready for download!
subject: Your archive is ready for download
@@ -1512,6 +1582,8 @@ en:
subject: Please confirm attempted sign in
title: Sign in attempt
warning:
+ appeal: Submit an appeal
+ appeal_description: If you believe this is an error, you can submit an appeal to the staff of %{instance}.
categories:
spam: Spam
violation: Content violates the following community guidelines
@@ -1523,7 +1595,6 @@ en:
suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed in about 30 days, but we will retain some basic data to prevent you from evading the suspension.
get_in_touch: If you believe this is an error, you can reply to this e-mail to get in touch with the staff of %{instance}.
reason: 'Reason:'
- review_server_policies: Review server policies
statuses: 'Posts that have been found in violation:'
subject:
delete_statuses: Your posts on %{acct} have been removed
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index d6376782d4f..03eefd0d5a4 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -27,6 +27,8 @@ en:
scheduled_at: Leave blank to publish the announcement immediately
starts_at: Optional. In case your announcement is bound to a specific time range
text: You can use post syntax. Please be mindful of the space the announcement will take up on the user's screen
+ appeal:
+ text: You can only appeal a strike once
defaults:
autofollow: People who sign up through the invite will automatically follow you
avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
@@ -119,6 +121,8 @@ en:
scheduled_at: Schedule publication
starts_at: Start of event
text: Announcement
+ appeal:
+ text: Explain why this decision should be reversed
defaults:
autofollow: Invite to follow your account
avatar: Avatar
@@ -197,6 +201,7 @@ en:
sign_up_requires_approval: Limit sign-ups
severity: Rule
notification_emails:
+ appeal: Someone appeals a moderator decision
digest: Send digest e-mails
favourite: Someone favourited your post
follow: Someone followed you
@@ -204,8 +209,8 @@ en:
mention: Someone mentioned you
pending_account: New account needs review
reblog: Someone boosted your post
- report: A new report is submitted
- trending_tag: A new trend requires approval
+ report: New report is submitted
+ trending_tag: New trend requires review
rule:
text: Rule
tag:
diff --git a/config/navigation.rb b/config/navigation.rb
index fc03a2a778b..3fc3747d595 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -20,7 +20,7 @@ SimpleNavigation::Configuration.run do |navigation|
n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_url, if: -> { current_user.functional? }
n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
- s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities}
+ s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities|^/disputes}
s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys}
s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
end
@@ -41,7 +41,7 @@ SimpleNavigation::Configuration.run do |navigation|
n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s|
s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url
s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports}
- s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts}
+ s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts|/admin/disputes}
s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}
s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
diff --git a/config/routes.rb b/config/routes.rb
index 5edb36519a8..f59f2a5bba5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -164,6 +164,12 @@ Rails.application.routes.draw do
resources :login_activities, only: [:index]
end
+ namespace :disputes do
+ resources :strikes, only: [:show] do
+ resource :appeal, only: [:create]
+ end
+ end
+
resources :media, only: [:show] do
get :player
end
@@ -324,6 +330,15 @@ Rails.application.routes.draw do
end
end
end
+
+ namespace :disputes do
+ resources :appeals, only: [:index] do
+ member do
+ post :approve
+ post :reject
+ end
+ end
+ end
end
get '/admin', to: redirect('/admin/dashboard', status: 302)
diff --git a/config/settings.yml b/config/settings.yml
index 06cee253240..e63788ba2fd 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -48,6 +48,7 @@ defaults: &defaults
report: true
pending_account: true
trending_tag: true
+ appeal: true
interactions:
must_be_follower: false
must_be_following: false
diff --git a/db/migrate/20220124141035_create_appeals.rb b/db/migrate/20220124141035_create_appeals.rb
new file mode 100644
index 00000000000..afb3efbd533
--- /dev/null
+++ b/db/migrate/20220124141035_create_appeals.rb
@@ -0,0 +1,14 @@
+class CreateAppeals < ActiveRecord::Migration[6.1]
+ def change
+ create_table :appeals do |t|
+ t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade }
+ t.belongs_to :account_warning, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
+ t.text :text, null: false, default: ''
+ t.datetime :approved_at
+ t.belongs_to :approved_by_account, foreign_key: { to_table: :accounts, on_delete: :nullify }
+ t.datetime :rejected_at
+ t.belongs_to :rejected_by_account, foreign_key: { to_table: :accounts, on_delete: :nullify }
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20220210153119_add_overruled_at_to_account_warnings.rb b/db/migrate/20220210153119_add_overruled_at_to_account_warnings.rb
new file mode 100644
index 00000000000..a082da774c9
--- /dev/null
+++ b/db/migrate/20220210153119_add_overruled_at_to_account_warnings.rb
@@ -0,0 +1,5 @@
+class AddOverruledAtToAccountWarnings < ActiveRecord::Migration[6.1]
+ def change
+ add_column :account_warnings, :overruled_at, :datetime
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fd4633d6952..8842dcd8c05 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2022_01_18_183123) do
+ActiveRecord::Schema.define(version: 2022_02_10_153119) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -135,6 +135,7 @@ ActiveRecord::Schema.define(version: 2022_01_18_183123) do
t.datetime "updated_at", null: false
t.bigint "report_id"
t.string "status_ids", array: true
+ t.datetime "overruled_at"
t.index ["account_id"], name: "index_account_warnings_on_account_id"
t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id"
end
@@ -243,6 +244,22 @@ ActiveRecord::Schema.define(version: 2022_01_18_183123) do
t.bigint "status_ids", array: true
end
+ create_table "appeals", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.bigint "account_warning_id", null: false
+ t.text "text", default: "", null: false
+ t.datetime "approved_at"
+ t.bigint "approved_by_account_id"
+ t.datetime "rejected_at"
+ t.bigint "rejected_by_account_id"
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["account_id"], name: "index_appeals_on_account_id"
+ t.index ["account_warning_id"], name: "index_appeals_on_account_warning_id", unique: true
+ t.index ["approved_by_account_id"], name: "index_appeals_on_approved_by_account_id"
+ t.index ["rejected_by_account_id"], name: "index_appeals_on_rejected_by_account_id"
+ end
+
create_table "backups", force: :cascade do |t|
t.bigint "user_id"
t.string "dump_file_name"
@@ -1031,6 +1048,10 @@ ActiveRecord::Schema.define(version: 2022_01_18_183123) do
add_foreign_key "announcement_reactions", "accounts", on_delete: :cascade
add_foreign_key "announcement_reactions", "announcements", on_delete: :cascade
add_foreign_key "announcement_reactions", "custom_emojis", on_delete: :cascade
+ add_foreign_key "appeals", "account_warnings", on_delete: :cascade
+ add_foreign_key "appeals", "accounts", column: "approved_by_account_id", on_delete: :nullify
+ add_foreign_key "appeals", "accounts", column: "rejected_by_account_id", on_delete: :nullify
+ add_foreign_key "appeals", "accounts", on_delete: :cascade
add_foreign_key "backups", "users", on_delete: :nullify
add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
diff --git a/spec/controllers/admin/disputes/appeals_controller_spec.rb b/spec/controllers/admin/disputes/appeals_controller_spec.rb
new file mode 100644
index 00000000000..6a06f940692
--- /dev/null
+++ b/spec/controllers/admin/disputes/appeals_controller_spec.rb
@@ -0,0 +1,53 @@
+require 'rails_helper'
+
+RSpec.describe Admin::Disputes::AppealsController, type: :controller do
+ render_views
+
+ before { sign_in current_user, scope: :user }
+
+ let(:target_account) { Fabricate(:account) }
+ let(:strike) { Fabricate(:account_warning, target_account: target_account, action: :suspend) }
+ let(:appeal) { Fabricate(:appeal, strike: strike, account: target_account) }
+
+ before do
+ target_account.suspend!
+ end
+
+ describe 'POST #approve' do
+ let(:current_user) { Fabricate(:user, admin: true) }
+
+ before do
+ allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil))
+ post :approve, params: { id: appeal.id }
+ end
+
+ it 'unsuspends a suspended account' do
+ expect(target_account.reload.suspended?).to be false
+ end
+
+ it 'redirects back to the strike page' do
+ expect(response).to redirect_to(disputes_strike_path(appeal.strike))
+ end
+
+ it 'notifies target account about approved appeal' do
+ expect(UserMailer).to have_received(:appeal_approved).with(target_account.user, appeal)
+ end
+ end
+
+ describe 'POST #reject' do
+ let(:current_user) { Fabricate(:user, admin: true) }
+
+ before do
+ allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil))
+ post :reject, params: { id: appeal.id }
+ end
+
+ it 'redirects back to the strike page' do
+ expect(response).to redirect_to(disputes_strike_path(appeal.strike))
+ end
+
+ it 'notifies target account about rejected appeal' do
+ expect(UserMailer).to have_received(:appeal_rejected).with(target_account.user, appeal)
+ end
+ end
+end
diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb
new file mode 100644
index 00000000000..faa571fc9e7
--- /dev/null
+++ b/spec/controllers/disputes/appeals_controller_spec.rb
@@ -0,0 +1,27 @@
+require 'rails_helper'
+
+RSpec.describe Disputes::AppealsController, type: :controller do
+ render_views
+
+ before { sign_in current_user, scope: :user }
+
+ let!(:admin) { Fabricate(:user, admin: true) }
+
+ describe '#create' do
+ let(:current_user) { Fabricate(:user) }
+ let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
+
+ before do
+ allow(AdminMailer).to receive(:new_appeal).and_return(double('email', deliver_later: nil))
+ post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } }
+ end
+
+ it 'notifies staff about new appeal' do
+ expect(AdminMailer).to have_received(:new_appeal).with(admin.account, Appeal.last)
+ end
+
+ it 'redirects back to the strike page' do
+ expect(response).to redirect_to(disputes_strike_path(strike.id))
+ end
+ end
+end
diff --git a/spec/controllers/disputes/strikes_controller_spec.rb b/spec/controllers/disputes/strikes_controller_spec.rb
new file mode 100644
index 00000000000..157f9ec3c76
--- /dev/null
+++ b/spec/controllers/disputes/strikes_controller_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+RSpec.describe Disputes::StrikesController, type: :controller do
+ render_views
+
+ before { sign_in current_user, scope: :user }
+
+ describe '#show' do
+ let(:current_user) { Fabricate(:user) }
+ let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
+
+ before do
+ get :show, params: { id: strike.id }
+ end
+
+ context 'when meant for the user' do
+ it 'returns http success' do
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context 'when meant for a different user' do
+ let(:strike) { Fabricate(:account_warning) }
+
+ it 'returns http forbidden' do
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+ end
+end
diff --git a/spec/fabricators/account_warning_fabricator.rb b/spec/fabricators/account_warning_fabricator.rb
index db161d4464d..72fe835d9aa 100644
--- a/spec/fabricators/account_warning_fabricator.rb
+++ b/spec/fabricators/account_warning_fabricator.rb
@@ -1,5 +1,6 @@
Fabricator(:account_warning) do
- account nil
- target_account nil
- text "MyText"
+ account
+ target_account(fabricator: :account)
+ text { Faker::Lorem.paragraph }
+ action 'suspend'
end
diff --git a/spec/fabricators/appeal_fabricator.rb b/spec/fabricators/appeal_fabricator.rb
new file mode 100644
index 00000000000..339363822d8
--- /dev/null
+++ b/spec/fabricators/appeal_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:appeal) do
+ strike(fabricator: :account_warning)
+ account { |attrs| attrs[:strike].target_account }
+ text { Faker::Lorem.paragraph }
+end
diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb
index 75ffbbf40f6..9c0372b47fc 100644
--- a/spec/mailers/previews/admin_mailer_preview.rb
+++ b/spec/mailers/previews/admin_mailer_preview.rb
@@ -15,4 +15,9 @@ class AdminMailerPreview < ActionMailer::Preview
def new_trending_links
AdminMailer.new_trending_links(Account.first, PreviewCard.limit(3))
end
+
+ # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal
+ def new_appeal
+ AdminMailer.new_appeal(Account.first, Appeal.first)
+ end
end
diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb
index 69b9b971eec..8de7d866967 100644
--- a/spec/mailers/previews/user_mailer_preview.rb
+++ b/spec/mailers/previews/user_mailer_preview.rb
@@ -82,6 +82,11 @@ class UserMailerPreview < ActionMailer::Preview
UserMailer.warning(User.first, AccountWarning.last)
end
+ # Preview this email at http://localhost:3000/rails/mailers/user_mailer/appeal_approved
+ def appeal_approved
+ UserMailer.appeal_approved(User.first, Appeal.last)
+ end
+
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token
def sign_in_token
UserMailer.sign_in_token(User.first.tap { |user| user.generate_sign_in_token }, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc)
diff --git a/spec/models/appeal_spec.rb b/spec/models/appeal_spec.rb
new file mode 100644
index 00000000000..14062dc4f4f
--- /dev/null
+++ b/spec/models/appeal_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Appeal, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end