From d086ed2c27c779dacd7581a0c14fc53a23496459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20U=C5=9Fakl=C4=B1?= Date: Sat, 7 Feb 2026 14:32:05 -0500 Subject: [PATCH] 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 --- .../en-GB/admin/manage/ban-reasons.json | 9 ++ public/language/en-GB/admin/manage/users.json | 3 + public/openapi/read.yaml | 2 + .../read/admin/manage/users/ban-reasons.yaml | 28 ++++++ public/src/admin/manage/users.js | 71 +++++++------- public/src/admin/manage/users/ban-reasons.js | 96 +++++++++++++++++++ public/src/modules/accounts/moderate.js | 54 ++++++----- src/controllers/admin/users.js | 5 + src/routes/admin.js | 1 + src/socket.io/admin.js | 5 + src/socket.io/admin/user.js | 8 ++ src/socket.io/user.js | 8 ++ src/user/bans.js | 30 +++++- src/views/admin/manage/users.tpl | 2 + src/views/admin/manage/users/ban-reasons.tpl | 47 +++++++++ .../partials/manage-ban-reasons-modal.tpl | 11 +++ src/views/emails/banned.tpl | 4 +- src/views/modals/temporary-ban.tpl | 40 +++++--- src/views/modals/temporary-mute.tpl | 54 ++++++++--- 19 files changed, 386 insertions(+), 92 deletions(-) create mode 100644 public/language/en-GB/admin/manage/ban-reasons.json create mode 100644 public/openapi/read/admin/manage/users/ban-reasons.yaml create mode 100644 public/src/admin/manage/users/ban-reasons.js create mode 100644 src/views/admin/manage/users/ban-reasons.tpl create mode 100644 src/views/admin/partials/manage-ban-reasons-modal.tpl diff --git a/public/language/en-GB/admin/manage/ban-reasons.json b/public/language/en-GB/admin/manage/ban-reasons.json new file mode 100644 index 0000000000..03b9f6462f --- /dev/null +++ b/public/language/en-GB/admin/manage/ban-reasons.json @@ -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 %1?" +} \ No newline at end of file diff --git a/public/language/en-GB/admin/manage/users.json b/public/language/en-GB/admin/manage/users.json index 6cd6a14aef..818cc2b87d 100644 --- a/public/language/en-GB/admin/manage/users.json +++ b/public/language/en-GB/admin/manage/users.json @@ -23,6 +23,7 @@ "purge": "Delete User(s) and Content", "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 (Optional)", + "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 permanently?", "alerts.confirm-ban-multi": "Do you really want to ban these users permanently?", diff --git a/public/openapi/read.yaml b/public/openapi/read.yaml index 3ec355bd95..f8469ddf07 100644 --- a/public/openapi/read.yaml +++ b/public/openapi/read.yaml @@ -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: diff --git a/public/openapi/read/admin/manage/users/ban-reasons.yaml b/public/openapi/read/admin/manage/users/ban-reasons.yaml new file mode 100644 index 0000000000..ea609e1709 --- /dev/null +++ b/public/openapi/read/admin/manage/users/ban-reasons.yaml @@ -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 \ No newline at end of file diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js index 81e0f812fa..3a24b1aa4e 100644 --- a/public/src/admin/manage/users.js +++ b/public/src/admin/manage/users.js @@ -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)); + } }); }); diff --git a/public/src/admin/manage/users/ban-reasons.js b/public/src/admin/manage/users/ban-reasons.js new file mode 100644 index 0000000000..927d427945 --- /dev/null +++ b/public/src/admin/manage/users/ban-reasons.js @@ -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; +}); + + diff --git a/public/src/modules/accounts/moderate.js b/public/src/modules/accounts/moderate.js index 6afc86076d..9ebbc7e45e 100644 --- a/public/src/modules/accounts/moderate.js +++ b/public/src/modules/accounts/moderate.js @@ -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)); + } }); } diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index 14e50bf9eb..f8738ff641 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -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 }); +}; \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js index 4788593c5a..4b6216d84a 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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); diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js index 6e5093d9a1..db7438e681 100644 --- a/src/socket.io/admin.js +++ b/src/socket.io/admin.js @@ -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); diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index 698f854597..beeeaae39b 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -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])); +}; diff --git a/src/socket.io/user.js b/src/socket.io/user.js index 48b491ab43..75ba2f5005 100644 --- a/src/socket.io/user.js +++ b/src/socket.io/user.js @@ -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]]'); diff --git a/src/user/bans.js b/src/user/bans.js index c52a24db6b..f1c13d1120 100644 --- a/src/user/bans.js +++ b/src/user/bans.js @@ -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; + }; }; diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index b3b9258e4c..f9ca3f841f 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -83,6 +83,8 @@
  • [[admin/manage/users:download-csv]]
  • [[admin/manage/users:custom-user-fields]]
  • +
  • [[admin/manage/users:ban-reasons]] +
  • diff --git a/src/views/admin/manage/users/ban-reasons.tpl b/src/views/admin/manage/users/ban-reasons.tpl new file mode 100644 index 0000000000..8cc2502fc3 --- /dev/null +++ b/src/views/admin/manage/users/ban-reasons.tpl @@ -0,0 +1,47 @@ +
    +
    +
    +

    [[admin/manage/ban-reasons:title]]

    +
    +
    + + +
    +
    + +
    +
    +
    + + + + + + + + + + + {{{ each reasons }}} + + + + + + + {{{ end }}} + +
    [[admin/manage/ban-reasons:reason-title]][[admin/manage/ban-reasons:reason-body]]
    + + {./title}{./parsedBody} +
    + + +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/views/admin/partials/manage-ban-reasons-modal.tpl b/src/views/admin/partials/manage-ban-reasons-modal.tpl new file mode 100644 index 0000000000..019c52fa1e --- /dev/null +++ b/src/views/admin/partials/manage-ban-reasons-modal.tpl @@ -0,0 +1,11 @@ +
    +
    + + +
    + +
    + + +
    +
    diff --git a/src/views/emails/banned.tpl b/src/views/emails/banned.tpl index 0f266bbdf2..03f6e7c1ca 100644 --- a/src/views/emails/banned.tpl +++ b/src/views/emails/banned.tpl @@ -22,9 +22,9 @@

    [[email:banned.text3]]

    -

    +

    {reason} -

    +
    {{{ end }}} diff --git a/src/views/modals/temporary-ban.tpl b/src/views/modals/temporary-ban.tpl index 33bd5c8b47..850289d59a 100644 --- a/src/views/modals/temporary-ban.tpl +++ b/src/views/modals/temporary-ban.tpl @@ -9,24 +9,38 @@
    -
    +
    - -
    -
    - - -
    -
    - - +
    + + +
    -
    +
    - - +
    + + {{{ if reasons.length }}} + + {{{ end }}} +
    + +
    diff --git a/src/views/modals/temporary-mute.tpl b/src/views/modals/temporary-mute.tpl index 0025a909ee..9dce754716 100644 --- a/src/views/modals/temporary-mute.tpl +++ b/src/views/modals/temporary-mute.tpl @@ -1,23 +1,47 @@
    -
    +
    - - -
    -
    - - -
    -
    - - +

    + [[admin/manage/users:temp-mute.explanation]] +

    -
    -
    - - +
    +
    +
    +
    + +
    + + +
    +
    +
    + +
    +
    +
    + + {{{ if reasons.length }}} + + {{{ end }}} +
    + +