When banning a user, a reason can be provided. The user will see this reason when trying to log in. Also log bans and unbans in the staff action logs.

This commit is contained in:
Neil Lalonde 2013-11-01 10:47:03 -04:00
parent 52b0c1c45f
commit 92a0729937
17 changed files with 180 additions and 26 deletions

View File

@ -0,0 +1,26 @@
/**
The modal for banning a user.
@class AdminBanUserController
@extends Discourse.Controller
@namespace Discourse
@uses Discourse.ModalFunctionality
@module Discourse
**/
Discourse.AdminBanUserController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, {
actions: {
ban: function() {
var duration = parseInt(this.get('duration'), 10);
if (duration > 0) {
this.get('model').ban(duration, this.get('reason')).then(function() {
window.location.reload();
}, function(e) {
var error = I18n.t('admin.user.ban_failed', { error: "http: " + e.status + " - " + e.body });
bootbox.alert(error);
});
}
}
}
});

View File

@ -142,21 +142,11 @@ Discourse.AdminUser = Discourse.User.extend({
return banned_at.format('L') + " - " + banned_till.format('L');
}.property('banned_till', 'banned_at'),
ban: function() {
var duration = parseInt(window.prompt(I18n.t('admin.user.ban_duration')), 10);
if (duration > 0) {
Discourse.ajax("/admin/users/" + this.id + "/ban", {
type: 'PUT',
data: {duration: duration}
}).then(function () {
// succeeded
window.location.reload();
}, function(e) {
// failure
var error = I18n.t('admin.user.ban_failed', { error: "http: " + e.status + " - " + e.body });
bootbox.alert(error);
});
}
ban: function(duration, reason) {
return Discourse.ajax("/admin/users/" + this.id + "/ban", {
type: 'PUT',
data: {duration: duration, reason: reason}
});
},
unban: function() {

View File

@ -21,6 +21,9 @@ Discourse.StaffActionLog = Discourse.Model.extend({
formatted += this.format('admin.logs.staff_actions.new_value', 'new_value');
formatted += this.format('admin.logs.staff_actions.previous_value', 'previous_value');
}
if (!this.get('useModalForDetails')) {
if (this.get('details')) formatted += this.get('details') + '<br/>';
}
return formatted;
}.property('ip_address', 'email'),
@ -33,7 +36,7 @@ Discourse.StaffActionLog = Discourse.Model.extend({
},
useModalForDetails: function() {
return (this.get('details') && this.get('details').length > 0);
return (this.get('details') && this.get('details').length > 100);
}.property('action_name'),
useCustomModalForDetails: function() {

View File

@ -28,6 +28,13 @@ Discourse.AdminUserRoute = Discourse.Route.extend({
controller.set('model', adminUser);
window.scrollTo(0, 0);
});
},
actions: {
showBanModal: function(user) {
Discourse.Route.showModal(this, 'admin_ban_user', user);
this.controllerFor('modal').set('modalClass', 'ban-user-modal');
}
}
});

View File

@ -0,0 +1,15 @@
<div class="modal-body">
<form>
{{i18n admin.user.ban_duration}}
{{textField value=duration maxlength="5" autofocus="autofocus" class="span2"}}
{{i18n admin.user.ban_duration_units}}
<br/>
{{i18n admin.user.ban_reason_label}}
<br/>
{{textField value=reason class="span8"}}
</form>
</div>
<div class="modal-footer">
<button class='btn btn-danger' {{action ban}} data-dismiss="modal"><i class='icon icon-ban-circle'></i>{{i18n admin.user.ban}}</button>
<a data-dismiss="modal">{{i18n cancel}}</a>
</div>

View File

@ -211,7 +211,7 @@
{{i18n admin.user.banned_explanation}}
{{else}}
{{#if canBan}}
<button class='btn btn-danger' {{action ban target="content"}}>
<button class='btn btn-danger' {{action showBanModal this}}>
<i class='icon icon-ban-circle'></i>
{{i18n admin.user.ban}}
</button>
@ -220,6 +220,22 @@
{{/if}}
</div>
</div>
{{#if isBanned}}
<div class='display-row'>
<div class='field'>{{i18n admin.user.banned_by}}</div>
<div class='long-value'>
{{#link-to 'adminUser' banned_by}}{{avatar banned_by imageSize="tiny"}}{{/link-to}}
{{#link-to 'adminUser' banned_by}}{{banned_by.username}}{{/link-to}}
</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.ban_reason}}</div>
<div class='long-value'>{{ban_reason}}</div>
</div>
{{/if}}
<div class='display-row'>
<div class='field'>{{i18n admin.user.blocked}}</div>
<div class='value'>{{blocked}}</div>

View File

@ -0,0 +1,12 @@
/**
A modal view for banning a user.
@class AdminBanUserView
@extends Discourse.ModalBodyView
@namespace Discourse
@module Discourse
**/
Discourse.AdminBanUserView = Discourse.ModalBodyView.extend({
templateName: 'admin/templates/modal/admin_ban_user',
title: I18n.t('admin.user.ban_modal_title')
});

View File

@ -5,6 +5,6 @@
</div>
<div class="modal-footer">
<button class='btn btn-primary' {{action saveAutoClose}} data-dismiss="modal">{{i18n topic.auto_close_save}}</button>
<a data-dismiss="modal">{{i18n topic.auto_close_cancel}}</a>
<a data-dismiss="modal">{{i18n cancel}}</a>
<button class='btn pull-right' {{action removeAutoClose}} data-dismiss="modal">{{i18n topic.auto_close_remove}}</button>
</div>

View File

@ -42,7 +42,7 @@ class Admin::UsersController < Admin::AdminController
@user.banned_till = params[:duration].to_i.days.from_now
@user.banned_at = DateTime.now
@user.save!
# TODO logging
StaffActionLogger.new(current_user).log_user_ban(@user, params[:reason])
render nothing: true
end
@ -51,7 +51,7 @@ class Admin::UsersController < Admin::AdminController
@user.banned_till = nil
@user.banned_at = nil
@user.save!
# TODO logging
StaffActionLogger.new(current_user).log_user_unban(@user)
render nothing: true
end

View File

@ -28,7 +28,11 @@ class SessionController < ApplicationController
if @user.confirm_password?(password)
if @user.is_banned?
render json: { error: I18n.t("login.banned", {date: I18n.l(@user.banned_till, format: :date_only)}) }
if reason = @user.ban_reason
render json: { error: I18n.t("login.banned_with_reason", {date: I18n.l(@user.banned_till, format: :date_only), reason: reason}) }
else
render json: { error: I18n.t("login.banned", {date: I18n.l(@user.banned_till, format: :date_only)}) }
end
return
end

View File

@ -360,6 +360,14 @@ class User < ActiveRecord::Base
banned_till && banned_till > DateTime.now
end
def ban_record
UserHistory.for(self, :ban_user).order('id DESC').first
end
def ban_reason
ban_record.try(:details) if is_banned?
end
# Use this helper to determine if the user has a particular trust level.
# Takes into account admin, etc.
def has_trust_level?(level)

View File

@ -18,7 +18,9 @@ class UserHistory < ActiveRecord::Base
:checked_for_custom_avatar,
:notified_about_avatar,
:notified_about_sequential_replies,
:notitied_about_dominating_topic)
:notitied_about_dominating_topic,
:ban_user,
:unban_user)
end
# Staff actions is a subset of all actions, used to audit actions taken by staff users.
@ -27,7 +29,9 @@ class UserHistory < ActiveRecord::Base
:change_trust_level,
:change_site_setting,
:change_site_customization,
:delete_site_customization]
:delete_site_customization,
:ban_user,
:unban_user]
end
def self.staff_action_ids
@ -48,6 +52,10 @@ class UserHistory < ActiveRecord::Base
query
end
def self.for(user, action_type)
self.where(target_user_id: user.id, action: UserHistory.actions[action_type])
end
def self.exists_for_user?(user, action_type, opts=nil)
opts = opts || {}
result = self.where(target_user_id: user.id, action: UserHistory.actions[action_type])

View File

@ -13,10 +13,12 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:flags_received_count,
:private_topics_count,
:can_delete_all_posts,
:can_be_deleted
:can_be_deleted,
:ban_reason
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :api_key, serializer: ApiKeySerializer, embed: :objects
has_one :banned_by, serializer: BasicUserSerializer, embed: :objects
def can_revoke_admin
scope.can_revoke_admin?(object)
@ -54,4 +56,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
api_key.present?
end
def banned_by
object.ban_record.try(:acting_user)
end
end

View File

@ -57,6 +57,23 @@ class StaffActionLogger
}))
end
def log_user_ban(user, reason, opts={})
raise Discourse::InvalidParameters.new('user is nil') unless user
UserHistory.create( params(opts).merge({
action: UserHistory.actions[:ban_user],
target_user_id: user.id,
details: reason
}))
end
def log_user_unban(user, opts={})
raise Discourse::InvalidParameters.new('user is nil') unless user
UserHistory.create( params(opts).merge({
action: UserHistory.actions[:unban_user],
target_user_id: user.id
}))
end
private
def params(opts)

View File

@ -657,7 +657,6 @@ en:
auto_close_notice: "This topic will automatically close %{timeLeft}."
auto_close_title: 'Auto-Close Settings'
auto_close_save: "Save"
auto_close_cancel: "Cancel"
auto_close_remove: "Don't Auto-Close This Topic"
progress:
@ -1260,6 +1259,8 @@ en:
change_site_setting: "change site setting"
change_site_customization: "change site customization"
delete_site_customization: "delete site customization"
ban_user: "ban user"
unban_user: "unban user"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."
@ -1331,7 +1332,11 @@ en:
user:
ban_failed: "Something went wrong banning this user {{error}}"
unban_failed: "Something went wrong unbanning this user {{error}}"
ban_duration: "How long would you like to ban the user for? (days)"
ban_duration: "How long would you like to ban the user for?"
ban_duration_units: "(days)"
ban_reason_label: "Why are you banning? When the user tries to log in, they will see this text. Keep it short."
ban_reason: "Reason for Ban"
banned_by: "Banned by"
delete_all_posts: "Delete all posts"
delete_all_posts_confirm: "You are about to delete %{posts} posts and %{topics} topics. Are you sure?"
ban: "Ban"
@ -1391,6 +1396,7 @@ en:
banned_explanation: "A banned user can't log in."
block_explanation: "A blocked user can't post or start topics."
trust_level_change_failed: "There was a problem changing the user's trust level."
ban_modal_title: "Ban User"
site_content:
none: "Choose a type of content to begin editing."

View File

@ -777,6 +777,7 @@ en:
activate_email: "You're almost done! We sent an activation email to <b>%{email}</b>. Please follow the instructions in the email to activate your account."
not_activated: "You can't log in yet. We sent an activation email to you. Please follow the instructions in the email to activate your account."
banned: "You can't log in until %{date}."
banned_with_reason: "You can't log in until %{date}. The reason you were banned: %{reason}"
errors: "%{errors}"
not_available: "Not available. Try %{suggestion}?"
something_already_taken: "Something went wrong, perhaps the username or email is already registered. Try the forgot password link."

View File

@ -115,4 +115,39 @@ describe StaffActionLogger do
json['header'].should == site_customization.header
end
end
describe "log_user_ban" do
let(:user) { Fabricate(:user) }
it "raises an error when arguments are missing" do
expect { logger.log_user_ban(nil, nil) }.to raise_error(Discourse::InvalidParameters)
expect { logger.log_user_ban(nil, "He was bad.") }.to raise_error(Discourse::InvalidParameters)
end
it "reason arg is optional" do
expect { logger.log_user_ban(user, nil) }.to_not raise_error
end
it "creates a new UserHistory record" do
reason = "He was a big meanie."
log_record = logger.log_user_ban(user, reason)
log_record.should be_valid
log_record.details.should == reason
log_record.target_user.should == user
end
end
describe "log_user_unban" do
let(:user) { Fabricate(:user, banned_at: 1.day.ago, banned_till: 7.days.from_now) }
it "raises an error when argument is missing" do
expect { logger.log_user_unban(nil) }.to raise_error(Discourse::InvalidParameters)
end
it "creates a new UserHistory record" do
log_record = logger.log_user_unban(user)
log_record.should be_valid
log_record.target_user.should == user
end
end
end