feat: ban/mute reasons (#13960)

add acp page to create reasons
add dropdown to insert them into reason
change reason field into textarea
translate and parse reason before sending ban email
This commit is contained in:
Barış Uşaklı
2026-02-07 14:32:05 -05:00
committed by GitHub
parent 15ba76e330
commit d086ed2c27
19 changed files with 386 additions and 92 deletions

View File

@@ -0,0 +1,9 @@
{
"title": "Manage Ban Reasons",
"create-reason": "Create Reason",
"edit-reason": "Edit Reason",
"reason-title": "Title",
"reason-body": "Body",
"ban-reasons-saved": "Ban reasons saved successfully",
"delete-reason-confirm-x": "Are you sure you want to delete the ban reason with the title <strong>%1</strong>?"
}

View File

@@ -23,6 +23,7 @@
"purge": "Delete <strong>User(s)</strong> and <strong>Content</strong>",
"download-csv": "Download CSV",
"custom-user-fields": "Custom User Fields",
"ban-reasons": "Ban Reasons",
"manage-groups": "Manage Groups",
"set-reputation": "Set Reputation",
"add-group": "Add Group",
@@ -77,9 +78,11 @@
"temp-ban.length": "Length",
"temp-ban.reason": "Reason <span class=\"text-muted\">(Optional)</span>",
"temp-ban.select-reason": "Select a reason",
"temp-ban.hours": "Hours",
"temp-ban.days": "Days",
"temp-ban.explanation": "Enter the length of time for the ban. Note that a time of 0 will be a considered a permanent ban.",
"temp-mute.explanation": "Enter the length of time for the mute. Note that a time of 0 will be a considered a permanent mute.",
"alerts.confirm-ban": "Do you really want to ban this user <strong>permanently</strong>?",
"alerts.confirm-ban-multi": "Do you really want to ban these users <strong>permanently</strong>?",

View File

@@ -134,6 +134,8 @@ paths:
$ref: 'read/admin/manage/users.yaml'
/api/admin/manage/users/custom-fields:
$ref: 'read/admin/manage/users/custom-fields.yaml'
/api/admin/manage/users/ban-reasons:
$ref: 'read/admin/manage/users/ban-reasons.yaml'
/api/admin/manage/registration:
$ref: 'read/admin/manage/registration.yaml'
/api/admin/manage/admins-mods:

View File

@@ -0,0 +1,28 @@
get:
tags:
- admin
summary: Manage ban reasons for users
responses:
"200":
description: ""
content:
application/json:
schema:
allOf:
- type: object
properties:
reasons:
type: array
items:
type: object
properties:
key:
type: string
title:
type: string
body:
type: string
parsedBody:
type: string
description: "body parsed with filter:parse.raw hook"
- $ref: ../../../../components/schemas/CommonProps.yaml#/CommonProps

View File

@@ -254,47 +254,52 @@ define('admin/manage/users', [
});
});
$('.ban-user-temporary').on('click', function () {
$('.ban-user-temporary').on('click', async function () {
const uids = getSelectedUids();
if (!uids.length) {
alerts.error('[[error:no-users-selected]]');
return false; // specifically to keep the menu open
}
const reasons = await socket.emit('user.getBanReasons');
const html = await app.parseAndTranslate('modals/temporary-ban', { reasons });
const modal = bootbox.dialog({
title: '[[user:ban-account]]',
message: html,
show: true,
onEscape: true,
buttons: {
close: {
label: '[[global:close]]',
className: 'btn-link',
},
submit: {
label: '[[admin/manage/users:alerts.button-ban-x, ' + uids.length + ']]',
callback: function () {
const formData = modal.find('form').serializeArray().reduce(function (data, cur) {
data[cur.name] = cur.value;
return data;
}, {});
const until = formData.length > 0 ? (
Date.now() + (formData.length * 1000 * 60 * 60 * (parseInt(formData.unit, 10) ? 24 : 1))
) : 0;
Benchpress.render('modals/temporary-ban', {}).then(function (html) {
const modal = bootbox.dialog({
title: '[[user:ban-account]]',
message: html,
show: true,
onEscape: true,
buttons: {
close: {
label: '[[global:close]]',
className: 'btn-link',
},
submit: {
label: '[[admin/manage/users:alerts.button-ban-x, ' + uids.length + ']]',
callback: function () {
const formData = modal.find('form').serializeArray().reduce(function (data, cur) {
data[cur.name] = cur.value;
return data;
}, {});
const until = formData.length > 0 ? (
Date.now() + (formData.length * 1000 * 60 * 60 * (parseInt(formData.unit, 10) ? 24 : 1))
) : 0;
Promise.all(uids.map(function (uid) {
return api.put('/users/' + encodeURIComponent(uid) + '/ban', {
until: until,
reason: formData.reason,
});
})).then(() => {
onSuccess('[[admin/manage/users:alerts.ban-success]]', '.ban', true);
}).catch(alerts.error);
},
Promise.all(uids.map(function (uid) {
return api.put('/users/' + encodeURIComponent(uid) + '/ban', {
until: until,
reason: formData.reason,
});
})).then(() => {
onSuccess('[[admin/manage/users:alerts.ban-success]]', '.ban', true);
}).catch(alerts.error);
},
},
});
},
});
modal.find('[data-key]').on('click', function () {
const reason = reasons.find(r => String(r.key) === $(this).attr('data-key'));
if (reason && reason.body) {
modal.find('[name="reason"]').val(translator.unescape(reason.body));
}
});
});

View File

@@ -0,0 +1,96 @@
define('admin/manage/user/ban-reasons', [
'benchpress', 'bootbox', 'alerts', 'translator', 'jquery-ui/widgets/sortable',
], function (benchpress, bootbox, alerts, translator) {
const manageBanReasons = {};
manageBanReasons.init = function () {
const table = $('table');
$('#new').on('click', () => showModal());
table.on('click', '[data-action="edit"]', function () {
const row = $(this).parents('[data-key]');
showModal(getDataFromEl(row));
});
table.on('click', '[data-action="delete"]', function () {
const row = $(this).parents('[data-key]');
const title = row.attr('data-title');
bootbox.confirm(`[[admin/manage/ban-reasons:delete-reason-confirm-x, "${title}"]]`, function (ok) {
if (!ok) {
return;
}
row.remove();
});
});
$('tbody').sortable({
handle: '[component="sort/handle"]',
axis: 'y',
zIndex: 9999,
});
$('#save').on('click', () => {
const reasons = [];
$('tbody tr[data-key]').each((index, el) => {
reasons.push(getDataFromEl($(el)));
});
socket.emit('admin.user.saveBanReasons', reasons, function (err) {
if (err) {
return alerts.error(err);
}
alerts.success('[[admin/manage/ban-reasons:ban-reasons-saved]]');
});
});
};
function getDataFromEl(el) {
return {
key: el.attr('data-key'),
title: el.attr('data-title'),
body: el.attr('data-body'),
};
}
async function showModal(reason = null) {
const html = await benchpress.render('admin/partials/manage-ban-reasons-modal', reason);
const modal = bootbox.dialog({
message: html,
onEscape: true,
title: reason ?
'[[admin/manage/ban-reasons:edit-reason]]' :
'[[admin/manage/ban-reasons:create-reason]]',
buttons: {
submit: {
label: '[[global:save]]',
callback: async function () {
const formData = modal.find('form').serializeObject();
formData.key = reason ? reason.key : Date.now();
formData.body = translator.escape(formData.body);
formData.parsedBody = translator.escape(await socket.emit('admin.parseRaw', formData.body));
app.parseAndTranslate('admin/manage/users/ban-reasons', 'reasons', {
reasons: [formData],
}, (html) => {
if (reason) {
const oldKey = reason.key;
$(`tbody [data-key="${oldKey}"]`).replaceWith(html);
} else {
$('tbody').append(html);
}
});
},
},
},
});
// bootbox translates message we want the translation keys to be preseved.
if (reason && reason.body) {
modal.find('[name="body"]').val(reason.body);
}
}
return manageBanReasons;
});

View File

@@ -1,11 +1,11 @@
'use strict';
define('forum/account/moderate', [
'benchpress',
'api',
'bootbox',
'alerts',
], function (Benchpress, api, bootbox, alerts) {
'translator',
], function (api, bootbox, alerts, translator) {
const AccountModerate = {};
AccountModerate.banAccount = function (theirid, onSuccess) {
@@ -83,31 +83,37 @@ define('forum/account/moderate', [
});
};
function throwModal(options) {
Benchpress.render(options.tpl, {}).then(function (html) {
const modal = bootbox.dialog({
title: options.title,
message: html,
show: true,
onEscape: true,
buttons: {
close: {
label: '[[global:close]]',
className: 'btn-link',
},
submit: {
label: options.title,
callback: function () {
const formData = modal.find('form').serializeArray().reduce(function (data, cur) {
data[cur.name] = cur.value;
return data;
}, {});
async function throwModal(options) {
const reasons = await socket.emit('user.getBanReasons');
const html = await app.parseAndTranslate(options.tpl, { reasons });
const modal = bootbox.dialog({
title: options.title,
message: html,
show: true,
onEscape: true,
buttons: {
close: {
label: '[[global:close]]',
className: 'btn-link',
},
submit: {
label: options.title,
callback: function () {
const formData = modal.find('form').serializeArray().reduce(function (data, cur) {
data[cur.name] = cur.value;
return data;
}, {});
options.onSubmit(formData);
},
options.onSubmit(formData);
},
},
});
},
});
modal.find('[data-key]').on('click', function () {
const reason = reasons.find(r => String(r.key) === $(this).attr('data-key'));
if (reason && reason.body) {
modal.find('[name="reason"]').val(translator.unescape(reason.body));
}
});
}

View File

@@ -314,3 +314,8 @@ usersController.customFields = async function (req, res) {
});
res.render('admin/manage/users/custom-fields', { fields: fields });
};
usersController.banReasons = async function (req, res) {
const reasons = await user.bans.getBanReasons();
res.render('admin/manage/users/ban-reasons', { reasons });
};

View File

@@ -23,6 +23,7 @@ module.exports = function (app, name, middleware, controllers) {
helpers.setupAdminPageRoute(app, `/${name}/manage/users`, middlewares, controllers.admin.users.index);
helpers.setupAdminPageRoute(app, `/${name}/manage/users/custom-fields`, middlewares, controllers.admin.users.customFields);
helpers.setupAdminPageRoute(app, `/${name}/manage/users/ban-reasons`, middlewares, controllers.admin.users.banReasons);
helpers.setupAdminPageRoute(app, `/${name}/manage/registration`, middlewares, controllers.admin.users.registrationQueue);
helpers.setupAdminPageRoute(app, `/${name}/manage/admins-mods`, middlewares, controllers.admin.adminsMods.get);

View File

@@ -9,6 +9,7 @@ const db = require('../database');
const privileges = require('../privileges');
const websockets = require('./index');
const batch = require('../batch');
const plugins = require('../plugins');
const index = require('./index');
const getAdminSearchDict = require('../admin/search').getDictionary;
@@ -126,4 +127,8 @@ SocketAdmin.clearSearchHistory = async function () {
});
};
SocketAdmin.parseRaw = async function (socket, text) {
return await plugins.hooks.fire('filter:parse.raw', text);
};
require('../promisify')(SocketAdmin);

View File

@@ -219,3 +219,11 @@ User.saveCustomFields = async function (socket, fields) {
await user.reloadCustomFieldWhitelist();
};
User.saveBanReasons = async function (socket, reasons) {
const keys = await db.getSortedSetRange('ban-reasons', 0, -1);
await db.delete('ban-reasons');
await db.deleteAll(keys.map(k => `ban-reason:${k}`));
const ids = reasons.map((f, i) => i);
await db.sortedSetAdd(`ban-reasons`, ids, ids);
await db.setObjectBulk(reasons.map((reason, i) => [`ban-reason:${i}`, reason]));
};

View File

@@ -174,6 +174,14 @@ SocketUser.editModerationNote = async function (socket, data) {
return await user.getModerationNotesByIds(data.uid, [data.id]);
};
SocketUser.getBanReasons = async function (socket) {
const canBan = await privileges.users.hasBanPrivilege(socket.uid);
if (!canBan) {
throw new Error('[[error:no-privileges]]');
}
return await user.bans.getBanReasons();
};
SocketUser.deleteUpload = async function (socket, data) {
if (!data || !data.name || !data.uid) {
throw new Error('[[error:invalid-data]]');

View File

@@ -7,6 +7,8 @@ const emailer = require('../emailer');
const db = require('../database');
const groups = require('../groups');
const privileges = require('../privileges');
const plugins = require('../plugins');
const translator = require('../translator');
module.exports = function (User) {
User.bans = {};
@@ -50,20 +52,27 @@ module.exports = function (User) {
}
// Email notification of ban
const username = await User.getUserField(uid, 'username');
const [username, settings] = await Promise.all([
User.getUserField(uid, 'username'),
User.getSettings(uid),
]);
const siteTitle = meta.config.title || 'NodeBB';
const data = {
await emailer.send('banned', uid, {
subject: `[[email:banned.subject, ${siteTitle}]]`,
username: username,
until: until ? (new Date(until)).toUTCString().replace(/,/g, '\\,') : false,
reason: reason,
};
await emailer.send('banned', uid, data).catch(err => winston.error(`[emailer.send] ${err.stack}`));
reason: await parseReason(reason, settings.userLang),
}).catch(err => winston.error(`[emailer.send] ${err.stack}`));
return banData;
};
async function parseReason(reason, lang) {
const parsed = await plugins.hooks.fire('filter:parse.raw', reason);
return await translator.translate(parsed, lang);
}
User.bans.unban = async function (uids, reason = '') {
const isArray = Array.isArray(uids);
uids = isArray ? uids : [uids];
@@ -155,4 +164,15 @@ module.exports = function (User) {
const banObj = await db.getObject(keys[0]);
return banObj && banObj.reason ? banObj.reason : '';
};
User.bans.getBanReasons = async function () {
const keys = await db.getSortedSetRange('ban-reasons', 0, -1);
const reasons = (await db.getObjects(keys.map(k => `ban-reason:${k}`))).filter(Boolean);
await Promise.all(reasons.map(async (reason, i) => {
reason.key = i;
reason.parsedBody = translator.escape(await plugins.hooks.fire('filter:parse.raw', reason.body || ''));
reason.body = translator.escape(reason.body);
}));
return reasons;
};
};

View File

@@ -83,6 +83,8 @@
<li><a target="_blank" href="#" class="dropdown-item rounded-1 export-csv" role="menuitem">[[admin/manage/users:download-csv]]</a></li>
<li><a class="dropdown-item rounded-1" href="{relative_path}/admin/manage/users/custom-fields">[[admin/manage/users:custom-user-fields]]</a>
</li>
<li><a class="dropdown-item rounded-1" href="{relative_path}/admin/manage/users/ban-reasons">[[admin/manage/users:ban-reasons]]</a>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,47 @@
<div class="manage-users d-flex flex-column gap-2 px-lg-4 h-100">
<div class="d-flex border-bottom py-2 m-0 sticky-top acp-page-main-header align-items-center justify-content-between flex-wrap gap-2">
<div class="">
<h4 class="fw-bold tracking-tight mb-0">[[admin/manage/ban-reasons:title]]</h4>
</div>
<div class="d-flex align-items-center gap-1">
<button id="new" class="btn btn-light btn-sm text-nowrap" type="button">
<i class="fa fa-fw fa-plus"></i> [[admin/manage/ban-reasons:create-reason]]
</button>
<button id="save" class="btn btn-primary btn-sm fw-semibold ff-secondary w-100 text-center text-nowrap">[[admin/admin:save-changes]]</button>
</div>
</div>
<div class="row flex-grow-1">
<div class="col-lg-12 d-flex flex-column gap-2">
<div class="table-responsive flex-grow-1">
<table class="table text-sm">
<thead>
<tr>
<th></th>
<th class="text-muted">[[admin/manage/ban-reasons:reason-title]]</th>
<th class="text-muted">[[admin/manage/ban-reasons:reason-body]]</th>
<th></th>
</tr>
</thead>
<tbody>
{{{ each reasons }}}
<tr data-key="{./key}" data-title="{./title}" data-body="{./body}">
<td class="" style="width: 32px;">
<a href="#" component="sort/handle" class="btn btn-light btn-sm d-none d-md-block ui-sortable-handle" style="cursor:grab;"><i class="fa fa-arrows-up-down text-muted"></i></a>
</td>
<td class="text-nowrap ">{./title}</td>
<td class="">{./parsedBody}</td>
<td class="">
<div class="d-flex justify-content-end gap-1">
<button data-action="edit" data-key="{./key}" class="btn btn-light btn-sm">[[admin/admin:edit]]</button>
<button data-action="delete" data-key="{./key}" class="btn btn-light btn-sm"><i class="fa fa-trash text-danger"></i></button>
</div>
</td>
</tr>
{{{ end }}}
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,11 @@
<form>
<div class="mb-3">
<label class="form-label">[[admin/manage/ban-reasons:reason-title]]</label>
<input class="form-control" type="text" name="title" value="{./title}">
</div>
<div class="mb-3">
<label class="form-label">[[admin/manage/ban-reasons:reason-body]]</label>
<textarea rows="8" class="form-control" type="text" name="body">{./body}</textarea>
</div>
</form>

View File

@@ -22,9 +22,9 @@
<p style="margin: 0;">
[[email:banned.text3]]
</p>
<p style="margin: 0; padding: 6px 0px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol; font-size: 13px; line-height: 26px; color: #666666;">
<div style="margin: 0; padding: 6px 0px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol; font-size: 13px; line-height: 26px; color: #666666;">
{reason}
</p>
</div>
</td>
</tr>
{{{ end }}}

View File

@@ -9,24 +9,38 @@
</div>
</div>
<div class="row">
<div class="col-5">
<div class="col-12">
<div class="mb-3">
<label class="form-label" for="length">[[admin/manage/users:temp-ban.length]]</label>
<input class="form-control" id="length" name="length" type="number" min="0" value="0" />
</div>
<div class="form-check form-check-inline">
<label class="form-check-label" for="unit-hours">[[admin/manage/users:temp-ban.hours]]</label>
<input class="form-check-input" type="radio" id="unit-hours" name="unit" value="0" checked />
</div>
<div class="form-check form-check-inline">
<label class="form-check-label" for="unit-days">[[admin/manage/users:temp-ban.days]]</label>
<input class="form-check-input" type="radio" id="unit-days" name="unit" value="1" />
<div class="d-flex gap-1">
<input class="form-control" id="length" name="length" type="number" min="0" value="0" />
<select class="form-select" id="unit" name="unit">
<option value="0">[[admin/manage/users:temp-ban.hours]]</option>
<option value="1">[[admin/manage/users:temp-ban.days]]</option>
</select>
</div>
</div>
</div>
<div class="col-7">
<div class="col-12">
<div class="mb-3">
<label class="form-label" for="reason">[[admin/manage/users:temp-ban.reason]]</label>
<input type="text" class="form-control" id="reason" name="reason" />
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label mb-0" for="reason">[[admin/manage/users:temp-ban.reason]]</label>
{{{ if reasons.length }}}
<div class="dropdown">
<button class="btn btn-light btn-sm dropdown-toggle" type="button" id="reasonDropdown" data-bs-toggle="dropdown" aria-expanded="false">
[[admin/manage/users:temp-ban.select-reason]]
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end p-1" aria-labelledby="reasonDropdown">
{{{ each reasons }}}
<li><a class="dropdown-item rounded-1" href="#" data-key="{./key}">{./title}</a></li>
{{{ end }}}
</ul>
</div>
{{{ end }}}
</div>
<textarea rows="8" type="text" class="form-control" id="reason" name="reason"></textarea>
</div>
</div>
</div>

View File

@@ -1,23 +1,47 @@
<form class="form">
<div class="row">
<div class="col-5">
<div class="col-12">
<div class="mb-3">
<label class="form-label" for="length">[[admin/manage/users:temp-ban.length]]</label>
<input class="form-control" id="length" name="length" type="number" min="0" value="1" />
</div>
<div class="form-check form-check-inline">
<label class="form-check-label" for="unit-hours">[[admin/manage/users:temp-ban.hours]]</label>
<input class="form-check-input" type="radio" id="unit-hours" name="unit" value="0" checked />
</div>
<div class="form-check form-check-inline">
<label class="form-check-label" for="unit-days">[[admin/manage/users:temp-ban.days]]</label>
<input class="form-check-input" type="radio" id="unit-days" name="unit" value="1" />
<p class="form-text">
[[admin/manage/users:temp-mute.explanation]]
</p>
</div>
</div>
<div class="col-7">
<div class="">
<label class="form-label" for="reason">[[admin/manage/users:temp-ban.reason]]</label>
<input type="text" class="form-control" id="reason" name="reason" />
</div>
<div class="row">
<div class="col-12">
<div class="mb-3">
<label class="form-label" for="length">[[admin/manage/users:temp-ban.length]]</label>
<div class="d-flex gap-1">
<input class="form-control" id="length" name="length" type="number" min="0" value="0" />
<select class="form-select" id="unit" name="unit">
<option value="0">[[admin/manage/users:temp-ban.hours]]</option>
<option value="1">[[admin/manage/users:temp-ban.days]]</option>
</select>
</div>
</div>
</div>
<div class="col-12">
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label mb-0" for="reason">[[admin/manage/users:temp-ban.reason]]</label>
{{{ if reasons.length }}}
<div class="dropdown">
<button class="btn btn-light btn-sm dropdown-toggle" type="button" id="reasonDropdown" data-bs-toggle="dropdown" aria-expanded="false">
[[admin/manage/users:temp-ban.select-reason]]
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end p-1" aria-labelledby="reasonDropdown">
{{{ each reasons }}}
<li><a class="dropdown-item rounded-1" href="#" data-key="{./key}">{./title}</a></li>
{{{ end }}}
</ul>
</div>
{{{ end }}}
</div>
<textarea rows="8" type="text" class="form-control" id="reason" name="reason"></textarea>
</div>
</div>
</div>