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>", "purge": "Delete <strong>User(s)</strong> and <strong>Content</strong>",
"download-csv": "Download CSV", "download-csv": "Download CSV",
"custom-user-fields": "Custom User Fields", "custom-user-fields": "Custom User Fields",
"ban-reasons": "Ban Reasons",
"manage-groups": "Manage Groups", "manage-groups": "Manage Groups",
"set-reputation": "Set Reputation", "set-reputation": "Set Reputation",
"add-group": "Add Group", "add-group": "Add Group",
@@ -77,9 +78,11 @@
"temp-ban.length": "Length", "temp-ban.length": "Length",
"temp-ban.reason": "Reason <span class=\"text-muted\">(Optional)</span>", "temp-ban.reason": "Reason <span class=\"text-muted\">(Optional)</span>",
"temp-ban.select-reason": "Select a reason",
"temp-ban.hours": "Hours", "temp-ban.hours": "Hours",
"temp-ban.days": "Days", "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-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": "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>?", "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' $ref: 'read/admin/manage/users.yaml'
/api/admin/manage/users/custom-fields: /api/admin/manage/users/custom-fields:
$ref: 'read/admin/manage/users/custom-fields.yaml' $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: /api/admin/manage/registration:
$ref: 'read/admin/manage/registration.yaml' $ref: 'read/admin/manage/registration.yaml'
/api/admin/manage/admins-mods: /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(); const uids = getSelectedUids();
if (!uids.length) { if (!uids.length) {
alerts.error('[[error:no-users-selected]]'); alerts.error('[[error:no-users-selected]]');
return false; // specifically to keep the menu open 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) { Promise.all(uids.map(function (uid) {
const modal = bootbox.dialog({ return api.put('/users/' + encodeURIComponent(uid) + '/ban', {
title: '[[user:ban-account]]', until: until,
message: html, reason: formData.reason,
show: true, });
onEscape: true, })).then(() => {
buttons: { onSuccess('[[admin/manage/users:alerts.ban-success]]', '.ban', true);
close: { }).catch(alerts.error);
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);
},
}, },
}, },
}); },
});
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'; 'use strict';
define('forum/account/moderate', [ define('forum/account/moderate', [
'benchpress',
'api', 'api',
'bootbox', 'bootbox',
'alerts', 'alerts',
], function (Benchpress, api, bootbox, alerts) { 'translator',
], function (api, bootbox, alerts, translator) {
const AccountModerate = {}; const AccountModerate = {};
AccountModerate.banAccount = function (theirid, onSuccess) { AccountModerate.banAccount = function (theirid, onSuccess) {
@@ -83,31 +83,37 @@ define('forum/account/moderate', [
}); });
}; };
function throwModal(options) { async function throwModal(options) {
Benchpress.render(options.tpl, {}).then(function (html) { const reasons = await socket.emit('user.getBanReasons');
const modal = bootbox.dialog({ const html = await app.parseAndTranslate(options.tpl, { reasons });
title: options.title, const modal = bootbox.dialog({
message: html, title: options.title,
show: true, message: html,
onEscape: true, show: true,
buttons: { onEscape: true,
close: { buttons: {
label: '[[global:close]]', close: {
className: 'btn-link', label: '[[global:close]]',
}, className: 'btn-link',
submit: { },
label: options.title, submit: {
callback: function () { label: options.title,
const formData = modal.find('form').serializeArray().reduce(function (data, cur) { callback: function () {
data[cur.name] = cur.value; const formData = modal.find('form').serializeArray().reduce(function (data, cur) {
return data; 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 }); 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`, middlewares, controllers.admin.users.index);
helpers.setupAdminPageRoute(app, `/${name}/manage/users/custom-fields`, middlewares, controllers.admin.users.customFields); 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/registration`, middlewares, controllers.admin.users.registrationQueue);
helpers.setupAdminPageRoute(app, `/${name}/manage/admins-mods`, middlewares, controllers.admin.adminsMods.get); 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 privileges = require('../privileges');
const websockets = require('./index'); const websockets = require('./index');
const batch = require('../batch'); const batch = require('../batch');
const plugins = require('../plugins');
const index = require('./index'); const index = require('./index');
const getAdminSearchDict = require('../admin/search').getDictionary; 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); require('../promisify')(SocketAdmin);

View File

@@ -219,3 +219,11 @@ User.saveCustomFields = async function (socket, fields) {
await user.reloadCustomFieldWhitelist(); 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]); 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) { SocketUser.deleteUpload = async function (socket, data) {
if (!data || !data.name || !data.uid) { if (!data || !data.name || !data.uid) {
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');

View File

@@ -7,6 +7,8 @@ const emailer = require('../emailer');
const db = require('../database'); const db = require('../database');
const groups = require('../groups'); const groups = require('../groups');
const privileges = require('../privileges'); const privileges = require('../privileges');
const plugins = require('../plugins');
const translator = require('../translator');
module.exports = function (User) { module.exports = function (User) {
User.bans = {}; User.bans = {};
@@ -50,20 +52,27 @@ module.exports = function (User) {
} }
// Email notification of ban // 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 siteTitle = meta.config.title || 'NodeBB';
const data = { await emailer.send('banned', uid, {
subject: `[[email:banned.subject, ${siteTitle}]]`, subject: `[[email:banned.subject, ${siteTitle}]]`,
username: username, username: username,
until: until ? (new Date(until)).toUTCString().replace(/,/g, '\\,') : false, until: until ? (new Date(until)).toUTCString().replace(/,/g, '\\,') : false,
reason: reason, reason: await parseReason(reason, settings.userLang),
}; }).catch(err => winston.error(`[emailer.send] ${err.stack}`));
await emailer.send('banned', uid, data).catch(err => winston.error(`[emailer.send] ${err.stack}`));
return banData; 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 = '') { User.bans.unban = async function (uids, reason = '') {
const isArray = Array.isArray(uids); const isArray = Array.isArray(uids);
uids = isArray ? uids : [uids]; uids = isArray ? uids : [uids];
@@ -155,4 +164,15 @@ module.exports = function (User) {
const banObj = await db.getObject(keys[0]); const banObj = await db.getObject(keys[0]);
return banObj && banObj.reason ? banObj.reason : ''; 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 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><a class="dropdown-item rounded-1" href="{relative_path}/admin/manage/users/custom-fields">[[admin/manage/users:custom-user-fields]]</a>
</li> </li>
<li><a class="dropdown-item rounded-1" href="{relative_path}/admin/manage/users/ban-reasons">[[admin/manage/users:ban-reasons]]</a>
</li>
</ul> </ul>
</div> </div>
</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;"> <p style="margin: 0;">
[[email:banned.text3]] [[email:banned.text3]]
</p> </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} {reason}
</p> </div>
</td> </td>
</tr> </tr>
{{{ end }}} {{{ end }}}

View File

@@ -9,24 +9,38 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-5"> <div class="col-12">
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="length">[[admin/manage/users:temp-ban.length]]</label> <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 class="d-flex gap-1">
</div> <input class="form-control" id="length" name="length" type="number" min="0" value="0" />
<div class="form-check form-check-inline"> <select class="form-select" id="unit" name="unit">
<label class="form-check-label" for="unit-hours">[[admin/manage/users:temp-ban.hours]]</label> <option value="0">[[admin/manage/users:temp-ban.hours]]</option>
<input class="form-check-input" type="radio" id="unit-hours" name="unit" value="0" checked /> <option value="1">[[admin/manage/users:temp-ban.days]]</option>
</div> </select>
<div class="form-check form-check-inline"> </div>
<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> </div>
</div> </div>
<div class="col-7"> <div class="col-12">
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="reason">[[admin/manage/users:temp-ban.reason]]</label> <div class="d-flex align-items-center justify-content-between mb-2">
<input type="text" class="form-control" id="reason" name="reason" /> <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> </div>
</div> </div>

View File

@@ -1,23 +1,47 @@
<form class="form"> <form class="form">
<div class="row"> <div class="row">
<div class="col-5"> <div class="col-12">
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="length">[[admin/manage/users:temp-ban.length]]</label> <p class="form-text">
<input class="form-control" id="length" name="length" type="number" min="0" value="1" /> [[admin/manage/users:temp-mute.explanation]]
</div> </p>
<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> </div>
</div> </div>
<div class="col-7"> </div>
<div class=""> <div class="row">
<label class="form-label" for="reason">[[admin/manage/users:temp-ban.reason]]</label> <div class="col-12">
<input type="text" class="form-control" id="reason" name="reason" /> <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> </div>
</div> </div>