mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 16:46:12 +01:00
feat: ability to mute users
new mute privilege
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"upload-files": "Upload Files",
|
||||
"signature": "Signature",
|
||||
"ban": "Ban",
|
||||
"mute": "Mute",
|
||||
"invite": "Invite",
|
||||
"search-content": "Search Content",
|
||||
"search-users": "Search Users",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"create.password": "Password",
|
||||
"create.password-confirm": "Confirm Password",
|
||||
|
||||
"temp-ban.length": "Ban Length",
|
||||
"temp-ban.length": "Length",
|
||||
"temp-ban.reason": "Reason <span class=\"text-muted\">(Optional)</span>",
|
||||
"temp-ban.hours": "Hours",
|
||||
"temp-ban.days": "Days",
|
||||
|
||||
@@ -124,6 +124,9 @@
|
||||
"already-unbookmarked": "You have already unbookmarked this post",
|
||||
|
||||
"cant-ban-other-admins": "You can't ban other admins!",
|
||||
"cant-mute-other-admins": "You can't mute other admins!",
|
||||
"user-muted-for-hours": "You have been muted, you will be able to post in %1 hour(s)",
|
||||
"user-muted-for-minutes": "You have been muted, you will be able to post in %1 minute(s)",
|
||||
"cant-make-banned-users-admin": "You can't make banned users admin.",
|
||||
"cant-remove-last-admin": "You are the only administrator. Add another user as an administrator before removing yourself as admin",
|
||||
"account-deletion-disabled": "Account deletion is disabled",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"ban_account": "Ban Account",
|
||||
"ban_account_confirm": "Do you really want to ban this user?",
|
||||
"unban_account": "Unban Account",
|
||||
"mute_account": "Mute Account",
|
||||
"unmute_account": "Unmute Account",
|
||||
"delete_account": "Delete Account",
|
||||
"delete_account_as_admin": "Delete <strong>Account</strong>",
|
||||
"delete_content": "Delete Account <strong>Content</strong>",
|
||||
@@ -171,6 +173,7 @@
|
||||
"info.banned-permanently": "Banned permanently",
|
||||
"info.banned-reason-label": "Reason",
|
||||
"info.banned-no-reason": "No reason given.",
|
||||
"info.muted-no-reason": "No reason given.",
|
||||
"info.username-history": "Username History",
|
||||
"info.email-history": "Email History",
|
||||
"info.moderation-note": "Moderation Note",
|
||||
|
||||
@@ -68,6 +68,8 @@ paths:
|
||||
$ref: 'write/users/uid/follow.yaml'
|
||||
/users/{uid}/ban:
|
||||
$ref: 'write/users/uid/ban.yaml'
|
||||
/users/{uid}/mute:
|
||||
$ref: 'write/users/uid/mute.yaml'
|
||||
/users/{uid}/tokens:
|
||||
$ref: 'write/users/uid/tokens.yaml'
|
||||
/users/{uid}/tokens/{token}:
|
||||
|
||||
61
public/openapi/write/users/uid/mute.yaml
Normal file
61
public/openapi/write/users/uid/mute.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
put:
|
||||
tags:
|
||||
- users
|
||||
summary: mute a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: uid
|
||||
schema:
|
||||
type: integer
|
||||
required: true
|
||||
description: uid of the user to mute
|
||||
example: 2
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
until:
|
||||
type: number
|
||||
description: UNIX timestamp of the mute expiry
|
||||
example: 1585775608076
|
||||
reason:
|
||||
type: string
|
||||
example: the reason for the mute
|
||||
responses:
|
||||
'200':
|
||||
description: successfully muted user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
delete:
|
||||
tags:
|
||||
- users
|
||||
summary: unmute a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: uid
|
||||
schema:
|
||||
type: integer
|
||||
required: true
|
||||
description: uid of the user to unmute
|
||||
example: 2
|
||||
responses:
|
||||
'200':
|
||||
description: successfully unmuted user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
@@ -54,7 +54,9 @@ define('forum/account/header', [
|
||||
components.get('account/ban').on('click', function () {
|
||||
banAccount(ajaxify.data.theirid);
|
||||
});
|
||||
components.get('account/mute').on('click', muteAccount);
|
||||
components.get('account/unban').on('click', unbanAccount);
|
||||
components.get('account/unmute').on('click', unmuteAccount);
|
||||
components.get('account/delete-account').on('click', handleDeleteEvent.bind(null, 'account'));
|
||||
components.get('account/delete-content').on('click', handleDeleteEvent.bind(null, 'content'));
|
||||
components.get('account/delete-all').on('click', handleDeleteEvent.bind(null, 'purge'));
|
||||
@@ -177,6 +179,49 @@ define('forum/account/header', [
|
||||
}).catch(alerts.error);
|
||||
}
|
||||
|
||||
function muteAccount() {
|
||||
Benchpress.render('admin/partials/temporary-mute', {}).then(function (html) {
|
||||
bootbox.dialog({
|
||||
className: 'mute-modal',
|
||||
title: '[[user:mute_account]]',
|
||||
message: html,
|
||||
show: true,
|
||||
buttons: {
|
||||
close: {
|
||||
label: '[[global:close]]',
|
||||
className: 'btn-link',
|
||||
},
|
||||
submit: {
|
||||
label: '[[user:mute_account]]',
|
||||
callback: function () {
|
||||
const formData = $('.mute-modal 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;
|
||||
|
||||
api.put('/users/' + ajaxify.data.theirid + '/mute', {
|
||||
until: until,
|
||||
reason: formData.reason || '',
|
||||
}).then(() => {
|
||||
ajaxify.refresh();
|
||||
}).catch(alerts.error);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unmuteAccount() {
|
||||
api.del('/users/' + ajaxify.data.theirid + '/mute').then(() => {
|
||||
ajaxify.refresh();
|
||||
}).catch(alerts.error);
|
||||
}
|
||||
|
||||
function flagAccount() {
|
||||
require(['flags'], function (flags) {
|
||||
flags.showFlagModal({
|
||||
|
||||
@@ -225,6 +225,53 @@ usersAPI.unban = async function (caller, data) {
|
||||
});
|
||||
};
|
||||
|
||||
usersAPI.mute = async function (caller, data) {
|
||||
if (!await privileges.users.hasMutePrivilege(caller.uid)) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
} else if (await user.isAdministrator(data.uid)) {
|
||||
throw new Error('[[error:cant-mute-other-admins]]');
|
||||
}
|
||||
await db.setObject(`user:${data.uid}`, {
|
||||
mutedUntil: data.until,
|
||||
mutedReason: data.reason || '[[user:info.muted-no-reason]]',
|
||||
});
|
||||
|
||||
await events.log({
|
||||
type: 'user-mute',
|
||||
uid: caller.uid,
|
||||
targetUid: data.uid,
|
||||
ip: caller.ip,
|
||||
reason: data.reason || undefined,
|
||||
});
|
||||
plugins.hooks.fire('action:user.muted', {
|
||||
callerUid: caller.uid,
|
||||
ip: caller.ip,
|
||||
uid: data.uid,
|
||||
until: data.until > 0 ? data.until : undefined,
|
||||
reason: data.reason || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
usersAPI.unmute = async function (caller, data) {
|
||||
if (!await privileges.users.hasMutePrivilege(caller.uid)) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
|
||||
await db.deleteObjectFields(`user:${data.uid}`, ['mutedUntil', 'mutedReason']);
|
||||
|
||||
await events.log({
|
||||
type: 'user-unmute',
|
||||
uid: caller.uid,
|
||||
targetUid: data.uid,
|
||||
ip: caller.ip,
|
||||
});
|
||||
plugins.hooks.fire('action:user.unmuted', {
|
||||
callerUid: caller.uid,
|
||||
ip: caller.ip,
|
||||
uid: data.uid,
|
||||
});
|
||||
};
|
||||
|
||||
async function isPrivilegedOrSelfAndPasswordMatch(caller, data) {
|
||||
const { uid } = caller;
|
||||
const isSelf = parseInt(uid, 10) === parseInt(data.uid, 10);
|
||||
|
||||
@@ -73,6 +73,7 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
|
||||
userData.isSelfOrAdminOrGlobalModerator = isSelf || isAdmin || isGlobalModerator;
|
||||
userData.canEdit = results.canEdit;
|
||||
userData.canBan = results.canBanUser;
|
||||
userData.canMute = results.canMuteUser;
|
||||
userData.canFlag = (await privileges.users.canFlag(callerUID, userData.uid)).flag;
|
||||
userData.canChangePassword = isAdmin || (isSelf && !meta.config['password:disableEdit']);
|
||||
userData.isSelf = isSelf;
|
||||
@@ -95,6 +96,7 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
|
||||
|
||||
userData.sso = results.sso.associations;
|
||||
userData.banned = Boolean(userData.banned);
|
||||
userData.muted = parseInt(userData.mutedUntil, 10) > Date.now();
|
||||
userData.website = escape(userData.website);
|
||||
userData.websiteLink = !userData.website.startsWith('http') ? `http://${userData.website}` : userData.website;
|
||||
userData.websiteName = userData.website.replace(validator.escape('http://'), '').replace(validator.escape('https://'), '');
|
||||
@@ -144,6 +146,7 @@ async function getAllData(uid, callerUID) {
|
||||
sso: plugins.hooks.fire('filter:auth.list', { uid: uid, associations: [] }),
|
||||
canEdit: privileges.users.canEdit(callerUID, uid),
|
||||
canBanUser: privileges.users.canBanUser(callerUID, uid),
|
||||
canMuteUser: privileges.users.canMuteUser(callerUID, uid),
|
||||
isBlocked: user.blocks.is(uid, callerUID),
|
||||
canViewInfo: privileges.global.can('view:users:info', callerUID),
|
||||
hasPrivateChat: messaging.hasPrivateChat(callerUID, uid),
|
||||
|
||||
@@ -111,6 +111,16 @@ Users.unban = async (req, res) => {
|
||||
helpers.formatApiResponse(200, res);
|
||||
};
|
||||
|
||||
Users.mute = async (req, res) => {
|
||||
await api.users.mute(req, { ...req.body, uid: req.params.uid });
|
||||
helpers.formatApiResponse(200, res);
|
||||
};
|
||||
|
||||
Users.unmute = async (req, res) => {
|
||||
await api.users.unmute(req, { ...req.body, uid: req.params.uid });
|
||||
helpers.formatApiResponse(200, res);
|
||||
};
|
||||
|
||||
Users.generateToken = async (req, res) => {
|
||||
await hasAdminPrivilege(req.uid, 'settings');
|
||||
if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10)) {
|
||||
|
||||
@@ -36,6 +36,8 @@ events.types = [
|
||||
'user-removeAdmin',
|
||||
'user-ban',
|
||||
'user-unban',
|
||||
'user-mute',
|
||||
'user-unmute',
|
||||
'user-delete',
|
||||
'user-deleteAccount',
|
||||
'user-deleteContent',
|
||||
|
||||
@@ -26,6 +26,7 @@ privsGlobal.privilegeLabels = [
|
||||
{ name: '[[admin/manage/privileges:view-groups]]' },
|
||||
{ name: '[[admin/manage/privileges:allow-local-login]]' },
|
||||
{ name: '[[admin/manage/privileges:ban]]' },
|
||||
{ name: '[[admin/manage/privileges:mute]]' },
|
||||
{ name: '[[admin/manage/privileges:view-users-info]]' },
|
||||
];
|
||||
|
||||
@@ -44,6 +45,7 @@ privsGlobal.userPrivilegeList = [
|
||||
'view:groups',
|
||||
'local:login',
|
||||
'ban',
|
||||
'mute',
|
||||
'view:users:info',
|
||||
];
|
||||
|
||||
|
||||
@@ -109,6 +109,21 @@ privsUsers.canBanUser = async function (callerUid, uid) {
|
||||
return data.canBan;
|
||||
};
|
||||
|
||||
privsUsers.canMuteUser = async function (callerUid, uid) {
|
||||
const privsGlobal = require('./global');
|
||||
const [canMute, isTargetAdmin] = await Promise.all([
|
||||
privsGlobal.can('mute', callerUid),
|
||||
privsUsers.isAdministrator(uid),
|
||||
]);
|
||||
|
||||
const data = await plugins.hooks.fire('filter:user.canMuteUser', {
|
||||
canMute: canMute && !isTargetAdmin,
|
||||
callerUid: callerUid,
|
||||
uid: uid,
|
||||
});
|
||||
return data.canMute;
|
||||
};
|
||||
|
||||
privsUsers.canFlag = async function (callerUid, uid) {
|
||||
const [userReputation, targetPrivileged, reporterPrivileged] = await Promise.all([
|
||||
user.getUserField(callerUid, 'reputation'),
|
||||
@@ -126,6 +141,7 @@ privsUsers.canFlag = async function (callerUid, uid) {
|
||||
};
|
||||
|
||||
privsUsers.hasBanPrivilege = async uid => await hasGlobalPrivilege('ban', uid);
|
||||
privsUsers.hasMutePrivilege = async uid => await hasGlobalPrivilege('mute', uid);
|
||||
privsUsers.hasInvitePrivilege = async uid => await hasGlobalPrivilege('invite', uid);
|
||||
|
||||
async function hasGlobalPrivilege(privilege, uid) {
|
||||
|
||||
@@ -36,6 +36,9 @@ function authenticatedRoutes() {
|
||||
setupApiRoute(router, 'put', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.ban);
|
||||
setupApiRoute(router, 'delete', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.unban);
|
||||
|
||||
setupApiRoute(router, 'put', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.mute);
|
||||
setupApiRoute(router, 'delete', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.unmute);
|
||||
|
||||
setupApiRoute(router, 'post', '/:uid/tokens', [...middlewares, middleware.assert.user], controllers.write.users.generateToken);
|
||||
setupApiRoute(router, 'delete', '/:uid/tokens/:token', [...middlewares, middleware.assert.user], controllers.write.users.deleteToken);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const intFields = [
|
||||
'uid', 'postcount', 'topiccount', 'reputation', 'profileviews',
|
||||
'banned', 'banned:expire', 'email:confirmed', 'joindate', 'lastonline',
|
||||
'lastqueuetime', 'lastposttime', 'followingCount', 'followerCount',
|
||||
'blocksCount', 'passwordExpiry',
|
||||
'blocksCount', 'passwordExpiry', 'mutedUntil',
|
||||
];
|
||||
|
||||
module.exports = function (User) {
|
||||
@@ -25,7 +25,7 @@ module.exports = function (User) {
|
||||
'aboutme', 'signature', 'uploadedpicture', 'profileviews', 'reputation',
|
||||
'postcount', 'topiccount', 'lastposttime', 'banned', 'banned:expire',
|
||||
'status', 'flags', 'followerCount', 'followingCount', 'cover:url',
|
||||
'cover:position', 'groupTitle',
|
||||
'cover:position', 'groupTitle', 'mutedUntil', 'mutedReason',
|
||||
];
|
||||
|
||||
User.guestData = {
|
||||
|
||||
@@ -18,7 +18,7 @@ module.exports = function (User) {
|
||||
return;
|
||||
}
|
||||
const [userData, isAdminOrMod] = await Promise.all([
|
||||
User.getUserFields(uid, ['uid', 'banned', 'joindate', 'email', 'reputation'].concat([field])),
|
||||
User.getUserFields(uid, ['uid', 'banned', 'mutedUntil', 'joindate', 'email', 'reputation'].concat([field])),
|
||||
privileges.categories.isAdminOrMod(cid, uid),
|
||||
]);
|
||||
|
||||
@@ -35,6 +35,16 @@ module.exports = function (User) {
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (userData.mutedUntil > now) {
|
||||
let muteLeft = ((userData.mutedUntil - now) / (1000 * 60));
|
||||
if (muteLeft > 60) {
|
||||
muteLeft = (muteLeft / 60).toFixed(0);
|
||||
throw new Error(`[[error:user-muted-for-hours, ${muteLeft}]]`);
|
||||
} else {
|
||||
throw new Error(`[[error:user-muted-for-minutes, ${muteLeft.toFixed(0)}]]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (now - userData.joindate < meta.config.initialPostDelay * 1000) {
|
||||
throw new Error(`[[error:user-too-new, ${meta.config.initialPostDelay}]]`);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
<thead>
|
||||
{{{ if !isAdminPriv }}}
|
||||
<tr class="privilege-table-header">
|
||||
<th colspan="3"></th>
|
||||
<th class="arrowed" colspan="6">
|
||||
[[admin/manage/categories:privileges.section-posting]]
|
||||
</th>
|
||||
<th class="arrowed" colspan="7">
|
||||
[[admin/manage/categories:privileges.section-viewing]]
|
||||
</th>
|
||||
<th class="arrowed" colspan="2">
|
||||
[[admin/manage/categories:privileges.section-moderation]]
|
||||
<th class="privilege-filters btn-toolbar" colspan="100">
|
||||
<!-- IF privileges.columnCountGroupOther -->
|
||||
<button type="button" data-filter="19,99" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-other]]</button>
|
||||
<!-- END -->
|
||||
<button type="button" data-filter="16,18" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-moderation]]</button>
|
||||
<button type="button" data-filter="3,8" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-posting]]</button>
|
||||
<button type="button" data-filter="9,15" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-viewing]]</button>
|
||||
</th>
|
||||
</tr><tr><!-- zebrastripe reset --></tr>
|
||||
{{{ end }}}
|
||||
@@ -65,9 +63,18 @@
|
||||
<label>[[admin/manage/privileges:user-privileges]]</label>
|
||||
<table class="table table-striped privilege-table">
|
||||
<thead>
|
||||
{{{ if !isAdminPriv }}}
|
||||
<tr class="privilege-table-header">
|
||||
<th colspan="15"></th>
|
||||
<th class="privilege-filters btn-toolbar" colspan="100">
|
||||
<!-- IF privileges.columnCountGroupOther -->
|
||||
<button type="button" data-filter="21,99" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-other]]</button>
|
||||
<!-- END -->
|
||||
<button type="button" data-filter="18,20" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-moderation]]</button>
|
||||
<button type="button" data-filter="10,17" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-posting]]</button>
|
||||
<button type="button" data-filter="3,9" class="btn btn-default pull-right">[[admin/manage/categories:privileges.section-viewing]]</button>
|
||||
</th>
|
||||
</tr><tr><!-- zebrastripe reset --></tr>
|
||||
{{{ end }}}
|
||||
<tr>
|
||||
<th colspan="2">[[admin/manage/categories:privileges.section-user]]</th>
|
||||
<th class="text-center">[[admin/manage/privileges:select-clear-all]]</th>
|
||||
|
||||
27
src/views/admin/partials/temporary-mute.tpl
Normal file
27
src/views/admin/partials/temporary-mute.tpl
Normal file
@@ -0,0 +1,27 @@
|
||||
<form class="form">
|
||||
<div class="row">
|
||||
<div class="col-xs-4">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<div class="col-xs-8">
|
||||
<div class="form-group">
|
||||
<label for="reason">[[admin/manage/users:temp-ban.reason]]</label>
|
||||
<input type="text" class="form-control" id="reason" name="reason" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-4 text-center">
|
||||
<div class="form-group units">
|
||||
<label>[[admin/manage/users:temp-ban.hours]]</label>
|
||||
<input type="radio" name="unit" value="0" checked />
|
||||
|
||||
<label>[[admin/manage/users:temp-ban.days]]</label>
|
||||
<input type="radio" name="unit" value="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
Reference in New Issue
Block a user