Add ability to filter audit log in admin UI (#13381)

This commit is contained in:
Eugen Rochko 2020-04-03 13:06:34 +02:00 committed by GitHub
parent 69558d2fe5
commit f65568f1d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 177 additions and 377 deletions

View file

@ -2,8 +2,18 @@
module Admin module Admin
class ActionLogsController < BaseController class ActionLogsController < BaseController
def index before_action :set_action_logs
@action_logs = Admin::ActionLog.page(params[:page])
def index; end
private
def set_action_logs
@action_logs = Admin::ActionLogFilter.new(filter_params).results.page(params[:page])
end
def filter_params
params.slice(:page, *Admin::ActionLogFilter::KEYS).permit(:page, *Admin::ActionLogFilter::KEYS)
end end
end end
end end

View file

@ -9,79 +9,8 @@ module Admin::ActionLogsHelper
end end
end end
def relevant_log_changes(log)
if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action)
log.recorded_changes.slice('domain')
elsif log.target_type == 'CustomEmoji' && log.action == :update
log.recorded_changes.slice('domain', 'visible_in_picker')
elsif log.target_type == 'User' && [:promote, :demote].include?(log.action)
log.recorded_changes.slice('moderator', 'admin')
elsif log.target_type == 'User' && [:change_email].include?(log.action)
log.recorded_changes.slice('email', 'unconfirmed_email')
elsif log.target_type == 'DomainBlock'
log.recorded_changes.slice('severity', 'reject_media')
elsif log.target_type == 'Status' && log.action == :update
log.recorded_changes.slice('sensitive')
elsif log.target_type == 'Announcement' && log.action == :update
log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day')
end
end
def log_extra_attributes(hash)
safe_join(hash.to_a.map { |key, value| safe_join([content_tag(:span, key, class: 'diff-key'), '=', log_change(value)]) }, ' ')
end
def log_change(val)
return content_tag(:span, val, class: 'diff-neutral') unless val.is_a?(Array)
safe_join([content_tag(:span, val.first, class: 'diff-old'), content_tag(:span, val.last, class: 'diff-new')], '→')
end
def icon_for_log(log)
case log.target_type
when 'Account', 'User'
'user'
when 'CustomEmoji'
'file'
when 'Report'
'flag'
when 'DomainBlock'
'lock'
when 'DomainAllow'
'plus-circle'
when 'EmailDomainBlock'
'envelope'
when 'Status'
'pencil'
when 'AccountWarning'
'warning'
when 'Announcement'
'bullhorn'
end
end
def class_for_log_icon(log)
case log.action
when :enable, :unsuspend, :unsilence, :confirm, :promote, :resolve
'positive'
when :create
opposite_verbs?(log) ? 'negative' : 'positive'
when :update, :reset_password, :disable_2fa, :memorialize, :change_email
'neutral'
when :demote, :silence, :disable, :suspend, :remove_avatar, :remove_header, :reopen
'negative'
when :destroy
opposite_verbs?(log) ? 'positive' : 'negative'
else
''
end
end
private private
def opposite_verbs?(log)
%w(DomainBlock EmailDomainBlock AccountWarning).include?(log.target_type)
end
def linkable_log_target(record) def linkable_log_target(record)
case record.class.name case record.class.name
when 'Account' when 'Account'
@ -99,7 +28,7 @@ module Admin::ActionLogsHelper
when 'AccountWarning' when 'AccountWarning'
link_to record.target_account.acct, admin_account_path(record.target_account_id) link_to record.target_account.acct, admin_account_path(record.target_account_id)
when 'Announcement' when 'Announcement'
link_to "##{record.id}", edit_admin_announcement_path(record.id) link_to truncate(record.text), edit_admin_announcement_path(record.id)
end end
end end
@ -118,7 +47,7 @@ module Admin::ActionLogsHelper
I18n.t('admin.action_logs.deleted_status') I18n.t('admin.action_logs.deleted_status')
end end
when 'Announcement' when 'Announcement'
"##{attributes['id']}" truncate(attributes['text'])
end end
end end
end end

View file

@ -10,6 +10,7 @@ module Admin::FilterHelper
InviteFilter::KEYS, InviteFilter::KEYS,
RelationshipFilter::KEYS, RelationshipFilter::KEYS,
AnnouncementFilter::KEYS, AnnouncementFilter::KEYS,
Admin::ActionLogFilter::KEYS,
].flatten.freeze ].flatten.freeze
def filter_link_to(text, link_to_params, link_class_params = link_to_params) def filter_link_to(text, link_to_params, link_class_params = link_to_params)

View file

@ -30,6 +30,10 @@ delegate(document, '.media-spoiler-hide-button', 'click', () => {
}); });
}); });
delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
target.form.submit();
});
const onDomainBlockSeverityChange = (target) => { const onDomainBlockSeverityChange = (target) => {
const rejectMediaDiv = document.querySelector('.input.with_label.domain_block_reject_media'); const rejectMediaDiv = document.querySelector('.input.with_label.domain_block_reject_media');
const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports'); const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');

View file

@ -418,6 +418,11 @@ body,
} }
} }
&--with-select strong {
display: block;
margin-bottom: 10px;
}
a { a {
display: inline-block; display: inline-block;
color: $darker-text-color; color: $darker-text-color;
@ -551,19 +556,22 @@ body,
} }
.log-entry { .log-entry {
margin-bottom: 20px;
line-height: 20px; line-height: 20px;
padding: 15px 0;
background: $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 4%);
&:last-child {
border-bottom: 0;
}
&__header { &__header {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding: 10px;
background: $ui-base-color;
color: $darker-text-color; color: $darker-text-color;
border-radius: 4px 4px 0 0;
font-size: 14px; font-size: 14px;
position: relative; padding: 0 10px;
} }
&__avatar { &__avatar {
@ -590,44 +598,6 @@ body,
color: $dark-text-color; color: $dark-text-color;
} }
&__extras {
background: lighten($ui-base-color, 6%);
border-radius: 0 0 4px 4px;
padding: 10px;
color: $darker-text-color;
font-family: $font-monospace, monospace;
font-size: 12px;
word-wrap: break-word;
min-height: 20px;
}
&__icon {
font-size: 28px;
margin-right: 10px;
color: $dark-text-color;
}
&__icon__overlay {
position: absolute;
top: 10px;
right: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
&.positive {
background: $success-green;
}
&.negative {
background: lighten($error-red, 12%);
}
&.neutral {
background: $ui-highlight-color;
}
}
a, a,
.username, .username,
.target { .target {
@ -635,18 +605,6 @@ body,
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
} }
.diff-old {
color: lighten($error-red, 12%);
}
.diff-neutral {
color: $secondary-text-color;
}
.diff-new {
color: $success-green;
}
} }
a.name-tag, a.name-tag,

View file

@ -0,0 +1,81 @@
# frozen_string_literal: true
class Admin::ActionLogFilter
KEYS = %i(
action_type
account_id
target_account_id
).freeze
ACTION_TYPE_MAP = {
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,
create_account_warning: { target_type: 'AccountWarning', action: 'create' }.freeze,
create_announcement: { target_type: 'Announcement', action: 'create' }.freeze,
create_custom_emoji: { target_type: 'CustomEmoji', action: 'create' }.freeze,
create_domain_allow: { target_type: 'DomainAllow', action: 'create' }.freeze,
create_domain_block: { target_type: 'DomainBlock', action: 'create' }.freeze,
create_email_domain_block: { target_type: 'EmailDomainBlock', action: 'create' }.freeze,
demote_user: { target_type: 'User', action: 'demote' }.freeze,
destroy_announcement: { target_type: 'Announcement', action: 'destroy' }.freeze,
destroy_custom_emoji: { target_type: 'CustomEmoji', action: 'destroy' }.freeze,
destroy_domain_allow: { target_type: 'DomainAllow', action: 'destroy' }.freeze,
destroy_domain_block: { target_type: 'DomainBlock', action: 'destroy' }.freeze,
destroy_email_domain_block: { target_type: 'EmailDomainBlock', action: 'destroy' }.freeze,
destroy_status: { target_type: 'Status', action: 'destroy' }.freeze,
disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze,
disable_custom_emoji: { target_type: 'CustomEmoji', action: 'disable' }.freeze,
disable_user: { target_type: 'User', action: 'disable' }.freeze,
enable_custom_emoji: { target_type: 'CustomEmoji', action: 'enable' }.freeze,
enable_user: { target_type: 'User', action: 'enable' }.freeze,
memorialize_account: { target_type: 'Account', action: 'memorialize' }.freeze,
promote_user: { target_type: 'User', action: 'promote' }.freeze,
remove_avatar_user: { target_type: 'User', action: 'remove_avatar' }.freeze,
reopen_report: { target_type: 'Report', action: 'reopen' }.freeze,
reset_password_user: { target_type: 'User', action: 'reset_password' }.freeze,
resolve_report: { target_type: 'Report', action: 'resolve' }.freeze,
silence_account: { target_type: 'Account', action: 'silence' }.freeze,
suspend_account: { target_type: 'Account', action: 'suspend' }.freeze,
unassigned_report: { target_type: 'Report', action: 'unassigned' }.freeze,
unsilence_account: { target_type: 'Account', action: 'unsilence' }.freeze,
unsuspend_account: { target_type: 'Account', action: 'unsuspend' }.freeze,
update_announcement: { target_type: 'Announcement', action: 'update' }.freeze,
update_custom_emoji: { target_type: 'CustomEmoji', action: 'update' }.freeze,
update_status: { target_type: 'Status', action: 'update' }.freeze,
}.freeze
attr_reader :params
def initialize(params)
@params = params
end
def results
scope = Admin::ActionLog.includes(:target)
params.each do |key, value|
next if key.to_s == 'page'
scope.merge!(scope_for(key.to_s, value.to_s.strip)) if value.present?
end
scope
end
private
def scope_for(key, value)
case key
when 'action_type'
Admin::ActionLog.where(ACTION_TYPE_MAP[value.to_sym])
when 'account_id'
Admin::ActionLog.where(account_id: value)
when 'target_account_id'
account = Account.find(value)
Admin::ActionLog.where(target: [account, account.user].compact)
else
raise "Unknown filter: #{key}"
end
end
end

View file

@ -53,7 +53,7 @@
.dashboard__counters__num= number_with_delimiter @account.targeted_reports.count .dashboard__counters__num= number_with_delimiter @account.targeted_reports.count
.dashboard__counters__label= t '.targeted_reports' .dashboard__counters__label= t '.targeted_reports'
%div %div
%div = link_to admin_action_logs_path(target_account_id: @account.id) do
.dashboard__counters__text .dashboard__counters__text
- if @account.local? && @account.user.nil? - if @account.local? && @account.user.nil?
%span.neutral= t('admin.accounts.deleted') %span.neutral= t('admin.accounts.deleted')

View file

@ -7,9 +7,3 @@
= t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe
.log-entry__timestamp .log-entry__timestamp
%time.formatted{ datetime: action_log.created_at.iso8601 } %time.formatted{ datetime: action_log.created_at.iso8601 }
.spacer
.log-entry__icon
= fa_icon icon_for_log(action_log)
.log-entry__icon__overlay{ class: class_for_log_icon(action_log) }
.log-entry__extras
= log_extra_attributes relevant_log_changes(action_log)

View file

@ -1,6 +1,28 @@
- content_for :page_title do - content_for :page_title do
= t('admin.action_logs.title') = t('admin.action_logs.title')
= render @action_logs - content_for :header_tags do
= javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous'
= form_tag admin_action_logs_url, method: 'GET', class: 'simple_form' do
= hidden_field_tag :target_account_id, params[:target_account_id] if params[:target_account_id].present?
.filters
.filter-subset.filter-subset--with-select
%strong= t('admin.action_logs.filter_by_user')
.input.select.optional
= select_tag :account_id, options_from_collection_for_select(Account.joins(:user).merge(User.staff), :id, :username, params[:account_id]), prompt: I18n.t('admin.accounts.moderation.all')
.filter-subset.filter-subset--with-select
%strong= t('admin.action_logs.filter_by_action')
.input.select.optional
= select_tag :action_type, options_for_select(Admin::ActionLogFilter::ACTION_TYPE_MAP.keys.map { |key| [I18n.t("admin.action_logs.action_types.#{key}"), key]}, params[:action_type]), prompt: I18n.t('admin.accounts.moderation.all')
- if @action_logs.empty?
%div.muted-hint.center-text
= t 'admin.action_logs.empty'
- else
.announcements-list
= render @action_logs
= paginate @action_logs = paginate @action_logs

View file

@ -2,6 +2,6 @@
Kaminari.configure do |config| Kaminari.configure do |config|
config.default_per_page = 40 config.default_per_page = 40
config.window = 1 config.window = 2
config.outer_window = 1 config.outer_window = 1
end end

View file

@ -195,6 +195,42 @@ en:
web: Web web: Web
whitelisted: Whitelisted whitelisted: Whitelisted
action_logs: action_logs:
action_types:
assigned_to_self_report: Assign Report
change_email_user: Change E-mail for User
confirm_user: Confirm User
create_account_warning: Create Warning
create_announcement: Create Announcement
create_custom_emoji: Create Custom Emoji
create_domain_allow: Create Domain Allow
create_domain_block: Create Domain Block
create_email_domain_block: Create E-mail Domain Block
demote_user: Demote User
destroy_announcement: Delete Announcement
destroy_custom_emoji: Delete Custom Emoji
destroy_domain_allow: Delete Domain Allow
destroy_domain_block: Delete Domain Block
destroy_email_domain_block: Delete e-mail domain block
destroy_status: Delete Status
disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji
disable_user: Disable User
enable_custom_emoji: Enable Custom Emoji
enable_user: Enable User
memorialize_account: Memorialize Account
promote_user: Promote User
remove_avatar_user: Remove Avatar
reopen_report: Reopen Report
reset_password_user: Reset Password
resolve_report: Resolve Report
silence_account: Silence Account
suspend_account: Suspend Account
unassigned_report: Unassign Report
unsilence_account: Unsilence Account
unsuspend_account: Unsuspend Account
update_announcement: Update Announcement
update_custom_emoji: Update Custom Emoji
update_status: Update Status
actions: actions:
assigned_to_self_report: "%{name} assigned report %{target} to themselves" assigned_to_self_report: "%{name} assigned report %{target} to themselves"
change_email_user: "%{name} changed the e-mail address of user %{target}" change_email_user: "%{name} changed the e-mail address of user %{target}"
@ -232,6 +268,9 @@ en:
update_custom_emoji: "%{name} updated emoji %{target}" update_custom_emoji: "%{name} updated emoji %{target}"
update_status: "%{name} updated status by %{target}" update_status: "%{name} updated status by %{target}"
deleted_status: "(deleted status)" deleted_status: "(deleted status)"
empty: No logs found.
filter_by_action: Filter by action
filter_by_user: Filter by user
title: Audit log title: Audit log
announcements: announcements:
destroyed_msg: Announcement successfully deleted! destroyed_msg: Announcement successfully deleted!

View file

@ -31,242 +31,4 @@ RSpec.describe Admin::ActionLogsHelper, type: :helper do
end end
end end
end end
describe '#relevant_log_changes' do
let(:log) { double(target_type: target_type, action: log_action, recorded_changes: recorded_changes) }
let(:recorded_changes) { double }
after do
hoge.relevant_log_changes(log)
end
context "log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action)" do
let(:target_type) { 'CustomEmoji' }
let(:log_action) { :enable }
it "calls log.recorded_changes.slice('domain')" do
expect(recorded_changes).to receive(:slice).with('domain')
end
end
context "log.target_type == 'CustomEmoji' && log.action == :update" do
let(:target_type) { 'CustomEmoji' }
let(:log_action) { :update }
it "calls log.recorded_changes.slice('domain', 'visible_in_picker')" do
expect(recorded_changes).to receive(:slice).with('domain', 'visible_in_picker')
end
end
context "log.target_type == 'User' && [:promote, :demote].include?(log.action)" do
let(:target_type) { 'User' }
let(:log_action) { :promote }
it "calls log.recorded_changes.slice('moderator', 'admin')" do
expect(recorded_changes).to receive(:slice).with('moderator', 'admin')
end
end
context "log.target_type == 'User' && [:change_email].include?(log.action)" do
let(:target_type) { 'User' }
let(:log_action) { :change_email }
it "calls log.recorded_changes.slice('email', 'unconfirmed_email')" do
expect(recorded_changes).to receive(:slice).with('email', 'unconfirmed_email')
end
end
context "log.target_type == 'DomainBlock'" do
let(:target_type) { 'DomainBlock' }
let(:log_action) { nil }
it "calls log.recorded_changes.slice('severity', 'reject_media')" do
expect(recorded_changes).to receive(:slice).with('severity', 'reject_media')
end
end
context "log.target_type == 'Status' && log.action == :update" do
let(:target_type) { 'Status' }
let(:log_action) { :update }
it "log.recorded_changes.slice('sensitive')" do
expect(recorded_changes).to receive(:slice).with('sensitive')
end
end
end
describe '#log_extra_attributes' do
after do
hoge.log_extra_attributes(hoge: 'hoge')
end
it "calls content_tag(:span, key, class: 'diff-key')" do
allow(hoge).to receive(:log_change).with(anything)
expect(hoge).to receive(:content_tag).with(:span, :hoge, class: 'diff-key')
end
it 'calls safe_join twice' do
expect(hoge).to receive(:safe_join).with(
['<span class="diff-key">hoge</span>',
'=',
'<span class="diff-neutral">hoge</span>']
)
expect(hoge).to receive(:safe_join).with([nil], ' ')
end
end
describe '#log_change' do
after do
hoge.log_change(val)
end
context '!val.is_a?(Array)' do
let(:val) { 'hoge' }
it "calls content_tag(:span, val, class: 'diff-neutral')" do
expect(hoge).to receive(:content_tag).with(:span, val, class: 'diff-neutral')
end
end
context 'val.is_a?(Array)' do
let(:val) { %w(foo bar) }
it 'calls #content_tag twice and #safe_join' do
expect(hoge).to receive(:content_tag).with(:span, 'foo', class: 'diff-old')
expect(hoge).to receive(:content_tag).with(:span, 'bar', class: 'diff-new')
expect(hoge).to receive(:safe_join).with([nil, nil], '→')
end
end
end
describe '#icon_for_log' do
subject { hoge.icon_for_log(log) }
context "log.target_type == 'Account'" do
let(:log) { double(target_type: 'Account') }
it 'returns "user"' do
expect(subject).to be 'user'
end
end
context "log.target_type == 'User'" do
let(:log) { double(target_type: 'User') }
it 'returns "user"' do
expect(subject).to be 'user'
end
end
context "log.target_type == 'CustomEmoji'" do
let(:log) { double(target_type: 'CustomEmoji') }
it 'returns "file"' do
expect(subject).to be 'file'
end
end
context "log.target_type == 'Report'" do
let(:log) { double(target_type: 'Report') }
it 'returns "flag"' do
expect(subject).to be 'flag'
end
end
context "log.target_type == 'DomainBlock'" do
let(:log) { double(target_type: 'DomainBlock') }
it 'returns "lock"' do
expect(subject).to be 'lock'
end
end
context "log.target_type == 'EmailDomainBlock'" do
let(:log) { double(target_type: 'EmailDomainBlock') }
it 'returns "envelope"' do
expect(subject).to be 'envelope'
end
end
context "log.target_type == 'Status'" do
let(:log) { double(target_type: 'Status') }
it 'returns "pencil"' do
expect(subject).to be 'pencil'
end
end
end
describe '#class_for_log_icon' do
subject { hoge.class_for_log_icon(log) }
%i(enable unsuspend unsilence confirm promote resolve).each do |action|
context "log.action == #{action}" do
let(:log) { double(action: action) }
it 'returns "positive"' do
expect(subject).to be 'positive'
end
end
end
context 'log.action == :create' do
context 'opposite_verbs?(log)' do
let(:log) { double(action: :create, target_type: 'DomainBlock') }
it 'returns "negative"' do
expect(subject).to be 'negative'
end
end
context '!opposite_verbs?(log)' do
let(:log) { double(action: :create, target_type: '') }
it 'returns "positive"' do
expect(subject).to be 'positive'
end
end
end
%i(update reset_password disable_2fa memorialize change_email).each do |action|
context "log.action == #{action}" do
let(:log) { double(action: action) }
it 'returns "neutral"' do
expect(subject).to be 'neutral'
end
end
end
%i(demote silence disable suspend remove_avatar remove_header reopen).each do |action|
context "log.action == #{action}" do
let(:log) { double(action: action) }
it 'returns "negative"' do
expect(subject).to be 'negative'
end
end
end
context 'log.action == :destroy' do
context 'opposite_verbs?(log)' do
let(:log) { double(action: :destroy, target_type: 'DomainBlock') }
it 'returns "positive"' do
expect(subject).to be 'positive'
end
end
context '!opposite_verbs?(log)' do
let(:log) { double(action: :destroy, target_type: '') }
it 'returns "negative"' do
expect(subject).to be 'negative'
end
end
end
end
end end