Admin/users (#8762)

* feat: wip admin/users

* feat: more work

* feat: more fixes

* feat: #8662, verified/unverified user groups

* feat: add filter

* feat: change user search to use filters array

* refactor: remove unused search call

* fix: tests

* fix: cant join system groups

* fix: upgrade script
v1.18.x
Barış Soner Uşaklı 5 years ago committed by GitHub
parent bfaeb27c11
commit 872bacf1c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -20,16 +20,10 @@
"add-group": "Add Group",
"invite": "Invite",
"new": "New User",
"pills.latest": "Latest Users",
"filter-by": "Filter by",
"pills.unvalidated": "Not Validated",
"pills.no-posts": "No Posts",
"pills.top-posters": "Top Posters",
"pills.top-rep": "Most Reputation",
"pills.inactive": "Inactive",
"pills.flagged": "Most Flagged",
"pills.validated": "Validated",
"pills.banned": "Banned",
"pills.search": "User Search",
"50-per-page": "50 per page",
"100-per-page": "100 per page",

@ -79,24 +79,6 @@ paths:
$ref: 'read/admin/manage/tags.yaml'
/api/admin/manage/users:
$ref: 'read/admin/manage/users.yaml'
/api/admin/manage/users/search:
$ref: 'read/admin/manage/users/search.yaml'
/api/admin/manage/users/latest:
$ref: 'read/admin/manage/users/latest.yaml'
/api/admin/manage/users/not-validated:
$ref: 'read/admin/manage/users/not-validated.yaml'
/api/admin/manage/users/no-posts:
$ref: 'read/admin/manage/users/no-posts.yaml'
/api/admin/manage/users/top-posters:
$ref: 'read/admin/manage/users/top-posters.yaml'
/api/admin/manage/users/most-reputation:
$ref: 'read/admin/manage/users/most-reputation.yaml'
/api/admin/manage/users/inactive:
$ref: 'read/admin/manage/users/inactive.yaml'
/api/admin/manage/users/flagged:
$ref: 'read/admin/manage/users/flagged.yaml'
/api/admin/manage/users/banned:
$ref: 'read/admin/manage/users/banned.yaml'
/api/admin/manage/registration:
$ref: 'read/admin/manage/registration.yaml'
/api/admin/manage/admins-mods:

@ -79,6 +79,8 @@ get:
description: Each privilege will have a key in this object
isPrivate:
type: boolean
isSystem:
type: boolean
columnCountUser:
type: number
columnCountUserOther:

@ -21,12 +21,12 @@ get:
type: number
resultsPerPage:
type: number
latest:
reverse:
type: boolean
search_display:
sortBy:
type: string
requireEmailConfirmation:
type: number
sort_online:
type: boolean
inviteOnly:
type: boolean
adminInviteOnly:

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get banned users
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get flagged users
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get inactive users
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get latest users
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get users with the most reputation
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get users with no posts
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get non-verified users
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -1,39 +0,0 @@
get:
tags:
- admin
summary: Get users via search term
responses:
"200":
description: ""
content:
application/json:
schema:
allOf:
- type: object
properties:
search_display:
type: string
matchCount:
type: number
query:
type: string
uidQuery:
type: string
usernameQuery:
type: string
emailQuery:
type: string
ipQuery:
type: string
pageCount:
type: number
resultsPerPage:
type: number
timing:
type: number
users:
type: array
items:
$ref: ../../../../components/schemas/UserObject.yaml#/UserObjectACP
- $ref: ../../../../components/schemas/CommonProps.yaml#/CommonProps
- $ref: ../../../../components/schemas/Pagination.yaml#/Pagination

@ -1,7 +0,0 @@
get:
tags:
- admin
summary: Get users with the most posts
responses:
"418":
description: "TODO: A proper response needs to be added. It is not really a teapot | Replace this responses block with the block from /manage/users/latest"

@ -6,17 +6,11 @@ define('admin/manage/users', [
var Users = {};
Users.init = function () {
var navPills = $('.nav-pills li');
var pathname = window.location.pathname;
if (!navPills.find('a[href^="' + pathname + '"]').length || pathname === config.relative_path + '/admin/manage/users') {
pathname = config.relative_path + '/admin/manage/users/latest';
}
navPills.removeClass('active').find('a[href^="' + pathname + '"]').parent().addClass('active');
$('#results-per-page').val(ajaxify.data.resultsPerPage).on('change', function () {
var query = utils.params();
query.resultsPerPage = $('#results-per-page').val();
ajaxify.go(window.location.pathname + '?' + $.param(query));
var qs = buildSearchQuery(query);
ajaxify.go(window.location.pathname + '?' + qs);
});
function getSelectedUids() {
@ -346,7 +340,7 @@ define('admin/manage/users', [
});
function handleUserCreate() {
$('#createUser').on('click', function () {
$('[data-action="create"]').on('click', function () {
Benchpress.parse('admin/partials/create_user_modal', {}, function (html) {
var modal = bootbox.dialog({
message: html,
@ -403,35 +397,45 @@ define('admin/manage/users', [
}, err => errorEl.translateHtml('[[admin/manage/users:alerts.error-x, ' + err.status.message + ']]').removeClass('hidden'));
}
var timeoutId = 0;
handleSearch();
handleUserCreate();
handleInvite();
$('#search-user-uid, #search-user-name, #search-user-email, #search-user-ip').on('keyup', function () {
handleSort();
handleFilter();
};
function handleSearch() {
var timeoutId = 0;
function doSearch() {
$('.fa-spinner').removeClass('hidden');
loadSearchPage({
searchBy: $('#user-search-by').val(),
query: $('#user-search').val(),
page: 1,
});
}
$('#user-search').on('keyup', function () {
if (timeoutId !== 0) {
clearTimeout(timeoutId);
timeoutId = 0;
}
var $this = $(this);
var type = $this.attr('data-search-type');
timeoutId = setTimeout(function () {
$('.fa-spinner').removeClass('hidden');
loadSearchPage({
searchBy: type,
query: $this.val(),
page: 1,
});
}, 250);
timeoutId = setTimeout(doSearch, 250);
});
handleUserCreate();
handleInvite();
};
$('#user-search-by').on('change', function () {
doSearch();
});
}
function loadSearchPage(query) {
var qs = decodeURIComponent($.param(query));
$.get(config.relative_path + '/api/admin/manage/users/search?' + qs, renderSearchResults).fail(function (xhrErr) {
var params = utils.params();
params.searchBy = query.searchBy;
params.query = query.query;
params.page = query.page;
var qs = decodeURIComponent($.param(params));
$.get(config.relative_path + '/api/admin/manage/users?' + qs, renderSearchResults).fail(function (xhrErr) {
if (xhrErr && xhrErr.responseJSON && xhrErr.responseJSON.error) {
app.alertError(xhrErr.responseJSON.error);
}
@ -448,14 +452,19 @@ define('admin/manage/users', [
$('.users-table tbody').append(html);
html.find('.timeago').timeago();
$('.fa-spinner').addClass('hidden');
if (!$('#user-search').val()) {
$('#user-found-notify').addClass('hidden');
$('#user-notfound-notify').addClass('hidden');
return;
}
if (data && data.users.length === 0) {
$('#user-notfound-notify').translateHtml('[[admin/manage/users:search.not-found]]')
.removeClass('hidden');
$('#user-found-notify').addClass('hidden');
} else {
$('#user-found-notify').translateHtml(translator.compile('admin/manage/users:alerts.x-users-found', data.matchCount, data.timing))
.removeClass('hidden');
$('#user-found-notify').translateHtml(
translator.compile('admin/manage/users:alerts.x-users-found', data.matchCount, data.timing)
).removeClass('hidden');
$('#user-notfound-notify').addClass('hidden');
}
});
@ -479,6 +488,75 @@ define('admin/manage/users', [
});
}
function buildSearchQuery(params) {
if ($('#user-search').val()) {
params.query = $('#user-search').val();
params.searchBy = $('#user-search-by').val();
} else {
delete params.query;
delete params.searchBy;
}
return decodeURIComponent($.param(params));
}
function handleSort() {
$('.users-table thead th').on('click', function () {
var $this = $(this);
var sortBy = $this.attr('data-sort');
if (!sortBy) {
return;
}
var params = utils.params();
params.sortBy = sortBy;
if (ajaxify.data.sortBy === sortBy) {
params.sortDirection = ajaxify.data.reverse ? 'asc' : 'desc';
} else {
params.sortDirection = 'desc';
}
var qs = buildSearchQuery(params);
ajaxify.go('admin/manage/users?' + qs);
});
}
function getFilters() {
var filters = [];
$('#filter-by').find('[data-filter-by]').each(function () {
if ($(this).find('.fa-check').length) {
filters.push($(this).attr('data-filter-by'));
}
});
return filters;
}
function handleFilter() {
var currentFilters = getFilters();
$('#filter-by').on('click', 'li', function () {
var $this = $(this);
$this.find('i').toggleClass('fa-check', !$this.find('i').hasClass('fa-check'));
return false;
});
$('#filter-by').on('hidden.bs.dropdown', function () {
var filters = getFilters();
var changed = filters.length !== currentFilters.length;
if (filters.length === currentFilters.length) {
filters.forEach(function (filter, i) {
if (filter !== currentFilters[i]) {
changed = true;
}
});
}
currentFilters = getFilters();
if (changed) {
var params = utils.params();
params.filters = filters;
var qs = buildSearchQuery(params);
ajaxify.go('admin/manage/users?' + qs);
}
});
}
return Users;
});

@ -37,7 +37,7 @@ define('forum/topic/change-owner', [
changeOwner();
});
autocomplete.user(modal.find('#username'), { notBanned: true }, function (ev, ui) {
autocomplete.user(modal.find('#username'), { filters: ['notbanned'] }, function (ev, ui) {
toUid = ui.item.user.uid;
checkButtonEnable();
});

@ -61,17 +61,20 @@ define('forum/users', ['translator', 'benchpress'], function (translator, Benchp
return loadPage(query);
}
query.term = username;
query.query = username;
query.sortBy = getSortBy();
var filters = [];
if ($('.search .online-only').is(':checked') || (activeSection === 'online')) {
query.onlineOnly = true;
filters.push('online');
}
if (activeSection === 'banned') {
query.bannedOnly = true;
filters.push('banned');
}
if (activeSection === 'flagged') {
query.flaggedOnly = true;
filters.push('flagged');
}
if (filters.length) {
query.filters = filters;
}
loadPage(query);

@ -67,7 +67,11 @@ groupsController.get = async function (req, res, next) {
async function getGroupNames() {
const groupNames = await db.getSortedSetRange('groups:createtime', 0, -1);
return groupNames.filter(name => name !== 'registered-users' && !groups.isPrivilegeGroup(name));
return groupNames.filter(name => name !== 'registered-users' &&
name !== 'verified-users' &&
name !== 'unverified-users' &&
!groups.isPrivilegeGroup(name)
);
}
groupsController.getCSV = async function (req, res) {

@ -13,19 +13,130 @@ const utils = require('../../utils');
const usersController = module.exports;
const userFields = ['uid', 'username', 'userslug', 'email', 'postcount', 'joindate', 'banned',
'reputation', 'picture', 'flags', 'lastonline', 'email:confirmed'];
const userFields = [
'uid', 'username', 'userslug', 'email', 'postcount', 'joindate', 'banned',
'reputation', 'picture', 'flags', 'lastonline', 'email:confirmed',
];
usersController.index = async function (req, res) {
if (req.query.query) {
await usersController.search(req, res);
} else {
await getUsers(req, res);
}
};
async function getUsers(req, res) {
const sortDirection = req.query.sortDirection || 'desc';
const reverse = sortDirection === 'desc';
const page = parseInt(req.query.page, 10) || 1;
let resultsPerPage = parseInt(req.query.resultsPerPage, 10) || 50;
if (![50, 100, 250, 500].includes(resultsPerPage)) {
resultsPerPage = 50;
}
let sortBy = validator.escape(req.query.sortBy || '');
const filterBy = Array.isArray(req.query.filters || []) ? (req.query.filters || []) : [req.query.filters];
const start = Math.max(0, page - 1) * resultsPerPage;
const stop = start + resultsPerPage - 1;
function buildSet() {
const sortToSet = {
postcount: 'users:postcount',
reputation: 'users:reputation',
joindate: 'users:joindate',
online: 'users:online',
flags: 'users:flags',
};
const set = [];
if (sortBy) {
set.push(sortToSet[sortBy]);
}
if (filterBy.includes('unverified')) {
set.push('group:unverified-users:members');
}
if (filterBy.includes('verified')) {
set.push('group:verified-users:members');
}
if (filterBy.includes('banned')) {
set.push('users:banned');
}
if (!set.length) {
set.push('users:online');
sortBy = 'online';
}
return set.length > 1 ? set : set[0];
}
async function getCount(set) {
if (Array.isArray(set)) {
return await db.sortedSetIntersectCard(set);
}
return await db.sortedSetCard(set);
}
async function getUids(set) {
let uids = [];
if (Array.isArray(set)) {
const weights = set.map((s, index) => (index ? 0 : 1));
uids = await db[reverse ? 'getSortedSetRevIntersect' : 'getSortedSetIntersect']({
sets: set,
start: start,
stop: stop,
weights: weights,
});
} else {
uids = await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](set, start, stop);
}
return uids;
}
async function getUsersWithFields(set) {
const uids = await getUids(set);
const [isAdmin, userData] = await Promise.all([
user.isAdministrator(uids),
user.getUsersWithFields(uids, userFields, req.uid),
]);
userData.forEach((user, index) => {
if (user) {
user.administrator = isAdmin[index];
}
});
return userData;
}
const set = buildSet();
const [count, users] = await Promise.all([
getCount(set),
getUsersWithFields(set),
]);
render(req, res, {
users: users.filter(user => user && parseInt(user.uid, 10)),
page: page,
pageCount: Math.max(1, Math.ceil(count / resultsPerPage)),
resultsPerPage: resultsPerPage,
reverse: reverse,
sortBy: sortBy,
});
}
usersController.search = async function (req, res) {
const sortDirection = req.query.sortDirection || 'desc';
const reverse = sortDirection === 'desc';
const page = parseInt(req.query.page, 10) || 1;
let resultsPerPage = parseInt(req.query.resultsPerPage, 10) || 50;
if (![50, 100, 250, 500].includes(resultsPerPage)) {
resultsPerPage = 50;
}
const searchData = await user.search({
uid: req.uid,
query: req.query.query,
searchBy: req.query.searchBy,
sortBy: req.query.sortBy,
sortDirection: sortDirection,
filters: req.query.filters,
page: page,
resultsPerPage: resultsPerPage,
findUids: async function (query, searchBy, hardCap) {
@ -58,48 +169,10 @@ usersController.search = async function (req, res) {
}
});
searchData.query = validator.escape(String(req.query.query || ''));
searchData.uidQuery = req.query.searchBy === 'uid' ? searchData.query : '';
searchData.usernameQuery = req.query.searchBy === 'username' ? searchData.query : '';
searchData.emailQuery = req.query.searchBy === 'email' ? searchData.query : '';
searchData.ipQuery = req.query.searchBy === 'uid' ? searchData.query : '';
searchData.resultsPerPage = resultsPerPage;
searchData.pagination = pagination.create(page, searchData.pageCount, req.query);
searchData.search_display = '';
res.render('admin/manage/users', searchData);
};
usersController.sortByJoinDate = async function (req, res) {
await getUsers('users:joindate', 'latest', undefined, undefined, req, res);
};
usersController.notValidated = async function (req, res) {
await getUsers('users:notvalidated', 'notvalidated', undefined, undefined, req, res);
};
usersController.noPosts = async function (req, res) {
await getUsers('users:postcount', 'noposts', '-inf', 0, req, res);
};
usersController.topPosters = async function (req, res) {
await getUsers('users:postcount', 'topposts', 0, '+inf', req, res);
};
usersController.mostReputaion = async function (req, res) {
await getUsers('users:reputation', 'mostreputation', 0, '+inf', req, res);
};
usersController.flagged = async function (req, res) {
await getUsers('users:flags', 'mostflags', 1, '+inf', req, res);
};
usersController.inactive = async function (req, res) {
const timeRange = 1000 * 60 * 60 * 24 * 30 * (parseInt(req.query.months, 10) || 3);
const cutoff = Date.now() - timeRange;
await getUsers('users:online', 'inactive', '-inf', cutoff, req, res);
};
usersController.banned = async function (req, res) {
await getUsers('users:banned', 'banned', undefined, undefined, req, res);
searchData.sortBy = req.query.sortBy;
searchData.reverse = reverse;
render(req, res, searchData);
};
usersController.registrationQueue = async function (req, res) {
@ -149,69 +222,21 @@ async function getInvites() {
return invitations;
}
async function getUsers(set, section, min, max, req, res) {
const page = parseInt(req.query.page, 10) || 1;
let resultsPerPage = parseInt(req.query.resultsPerPage, 10) || 50;
if (![50, 100, 250, 500].includes(resultsPerPage)) {
resultsPerPage = 50;
}
const start = Math.max(0, page - 1) * resultsPerPage;
const stop = start + resultsPerPage - 1;
const byScore = min !== undefined && max !== undefined;
async function getCount() {
if (byScore) {
return await db.sortedSetCount(set, min, max);
} else if (set === 'users:banned' || set === 'users:notvalidated') {
return await db.sortedSetCard(set);
}
return await db.getObjectField('global', 'userCount');
}
async function getUsersWithFields() {
let uids;
if (byScore) {
uids = await db.getSortedSetRevRangeByScore(set, start, resultsPerPage, max, min);
} else {
uids = await user.getUidsFromSet(set, start, stop);
}
const [isAdmin, userData] = await Promise.all([
user.isAdministrator(uids),
user.getUsersWithFields(uids, userFields, req.uid),
]);
userData.forEach((user, index) => {
if (user) {
user.administrator = isAdmin[index];
}
});
return userData;
}
const [count, users] = await Promise.all([
getCount(),
getUsersWithFields(),
]);
const data = {
users: users.filter(user => user && parseInt(user.uid, 10)),
page: page,
pageCount: Math.max(1, Math.ceil(count / resultsPerPage)),
resultsPerPage: resultsPerPage,
};
data[section] = true;
render(req, res, data);
}
function render(req, res, data) {
data.search_display = 'hidden';
data.pagination = pagination.create(data.page, data.pageCount, req.query);
data.requireEmailConfirmation = meta.config.requireEmailConfirmation;
var registrationType = meta.config.registrationType;
const registrationType = meta.config.registrationType;
data.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only';
data.adminInviteOnly = registrationType === 'admin-invite-only';
data['sort_' + data.sortBy] = true;
if (req.query.searchBy) {
data['searchBy_' + validator.escape(String(req.query.searchBy))] = true;
}
const filterBy = Array.isArray(req.query.filters || []) ? (req.query.filters || []) : [req.query.filters];
filterBy.forEach(function (filter) {
data['filterBy_' + validator.escape(String(filter))] = true;
});
res.render('admin/manage/users', data);
}

@ -21,7 +21,7 @@ usersController.index = async function (req, res, next) {
flagged: usersController.getFlaggedUsers,
};
if (req.query.term) {
if (req.query.query) {
await usersController.search(req, res, next);
} else if (sectionToController[section]) {
await sectionToController[section](req, res, next);
@ -35,19 +35,25 @@ usersController.search = async function (req, res) {
privileges.global.can('search:users', req.uid),
user.isPrivileged(req.uid),
]);
if (!allowed || ((req.query.searchBy === 'ip' || req.query.searchBy === 'email' || req.query.bannedOnly === 'true' || req.query.flaggedOnly === 'true') && !isPrivileged)) {
let filters = req.query.filters || [];
filters = Array.isArray(filters) ? filters : [filters];
if (!allowed ||
((
req.query.searchBy === 'ip' ||
req.query.searchBy === 'email' ||
filters.includes('banned') ||
filters.includes('flagged')
) && !isPrivileged)
) {
throw new Error('[[error:no-privileges]]');
}
const [searchData, isAdminOrGlobalMod] = await Promise.all([
user.search({
query: req.query.term,
query: req.query.query,
searchBy: req.query.searchBy || 'username',
page: req.query.page || 1,
sortBy: req.query.sortBy || 'joindate',
onlineOnly: req.query.onlineOnly === 'true',
bannedOnly: req.query.bannedOnly === 'true',
flaggedOnly: req.query.flaggedOnly === 'true',
filters: filters,
}),
user.isAdminOrGlobalMod(req.uid),
]);

@ -70,7 +70,7 @@ Groups.join = async (req, res) => {
if (!res.locals.privileges.isAdmin) {
// Admin and privilege groups unjoinable client-side
if (group.name === 'administrators' || groups.isPrivilegeGroup(group.name)) {
if (groups.systemGroups.includes(group.name) || groups.isPrivilegeGroup(group.name)) {
throw new Error('[[error:not-allowed]]');
}

@ -67,7 +67,7 @@ module.exports = function (Groups) {
function isSystemGroup(data) {
return data.system === true || parseInt(data.system, 10) === 1 ||
data.name === 'administrators' || data.name === 'registered-users' || data.name === 'Global Moderators' ||
Groups.systemGroups.includes(data.name) ||
Groups.isPrivilegeGroup(data.name);
}

@ -25,14 +25,22 @@ require('./cache')(Groups);
Groups.ephemeralGroups = ['guests', 'spiders'];
Groups.systemGroups = [
'registered-users',
'verified-users',
'unverified-users',
'administrators',
'Global Moderators',
];
Groups.getEphemeralGroup = function (groupName) {
return {
name: groupName,
slug: slugify(groupName),
description: '',
deleted: '0',
hidden: '0',
system: '1',
deleted: 0,
hidden: 0,
system: 1,
};
};

@ -82,7 +82,7 @@ module.exports = function (Groups) {
});
} catch (err) {
if (err && err.message !== '[[error:group-already-exists]]') {
winston.error('[groups.join] Could not create new hidden group', err.stack);
winston.error('[groups.join] Could not create new hidden group (' + groupName + ')\n' + err.stack);
throw err;
}
}
@ -90,7 +90,10 @@ module.exports = function (Groups) {
}
async function setGroupTitleIfNotSet(groupNames, uid) {
groupNames = groupNames.filter(groupName => groupName !== 'registered-users' && !Groups.isPrivilegeGroup(groupName));
const ignore = ['registered-users', 'verified-users', 'unverified-users'];
groupNames = groupNames.filter(
groupName => !ignore.includes(groupName) && !Groups.isPrivilegeGroup(groupName)
);
if (!groupNames.length) {
return;
}

@ -3,6 +3,7 @@
const meta = require('../meta');
const user = require('../user');
const plugins = require('../plugins');
const privileges = require('../privileges');
const sockets = require('../socket.io');
@ -52,12 +53,13 @@ module.exports = function (Messaging) {
throw new Error('[[error:chat-message-editing-disabled]]');
}
const userData = await user.getUserFields(uid, ['banned', 'email:confirmed']);
const userData = await user.getUserFields(uid, ['banned']);
if (userData.banned) {
throw new Error('[[error:user-banned]]');
}
if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) {
throw new Error('[[error:email-not-confirmed]]');
const canChat = await privileges.global.can('chat', uid);
if (!canChat) {
throw new Error('[[error:no-privileges]]');
}
const [isAdmin, messageData] = await Promise.all([

@ -5,6 +5,7 @@ const validator = require('validator');
const db = require('../database');
const user = require('../user');
const privileges = require('../privileges');
const plugins = require('../plugins');
const meta = require('../meta');
const utils = require('../utils');
@ -201,13 +202,13 @@ Messaging.canMessageUser = async (uid, toUid) => {
throw new Error('[[error:no-user]]');
}
const userData = await user.getUserFields(uid, ['banned', 'email:confirmed']);
const userData = await user.getUserFields(uid, ['banned']);
if (userData.banned) {
throw new Error('[[error:user-banned]]');
}
if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) {
throw new Error('[[error:email-not-confirmed-chat]]');
const canChat = await privileges.global.can('chat', uid);
if (!canChat) {
throw new Error('[[error:no-privileges]]');
}
const results = await utils.promiseParallel({
@ -237,13 +238,13 @@ Messaging.canMessageRoom = async (uid, roomId) => {
throw new Error('[[error:not-in-room]]');
}
const userData = await user.getUserFields(uid, ['banned', 'email:confirmed']);
const userData = await user.getUserFields(uid, ['banned']);
if (userData.banned) {
throw new Error('[[error:user-banned]]');
}
if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) {
throw new Error('[[error:email-not-confirmed-chat]]');
const canChat = await privileges.global.can('chat', uid);
if (!canChat) {
throw new Error('[[error:no-privileges]]');
}
await plugins.fireHook('static:messaging.canMessageRoom', {

@ -79,7 +79,6 @@ module.exports = function (privileges) {
'admin.user.deleteUsers': 'admin:users',
'admin.user.deleteUsersAndContent': 'admin:users',
'admin.user.createUser': 'admin:users',
'admin.user.search': 'admin:users',
'admin.user.invite': 'admin:users',
'admin.getSearchDict': 'admin:settings',

@ -119,13 +119,15 @@ helpers.getGroupPrivileges = async function (cid, groupPrivileges) {
groupNames = groups.ephemeralGroups.concat(groupNames);
moveToFront(groupNames, 'Global Moderators');
moveToFront(groupNames, 'unverified-users');
moveToFront(groupNames, 'verified-users');
moveToFront(groupNames, 'registered-users');
const adminIndex = groupNames.indexOf('administrators');
if (adminIndex !== -1) {
groupNames.splice(adminIndex, 1);
}
const groupData = await groups.getGroupsFields(groupNames, ['private']);
const groupData = await groups.getGroupsFields(groupNames, ['private', 'system']);
const memberData = groupNames.map(function (member, index) {
const memberPrivs = {};
@ -137,6 +139,7 @@ helpers.getGroupPrivileges = async function (cid, groupPrivileges) {
nameEscaped: translator.escape(validator.escape(member)),
privileges: memberPrivs,
isPrivate: groupData[index] && !!groupData[index].private,
isSystem: groupData[index] && !!groupData[index].system,
};
});
return memberData;

@ -16,16 +16,7 @@ module.exports = function (app, middleware, controllers) {
helpers.setupAdminPageRoute(app, '/admin/manage/privileges/:cid?', middleware, middlewares, controllers.admin.privileges.get);
helpers.setupAdminPageRoute(app, '/admin/manage/tags', middleware, middlewares, controllers.admin.tags.get);
helpers.setupAdminPageRoute(app, '/admin/manage/users', middleware, middlewares, controllers.admin.users.sortByJoinDate);
helpers.setupAdminPageRoute(app, '/admin/manage/users/search', middleware, middlewares, controllers.admin.users.search);
helpers.setupAdminPageRoute(app, '/admin/manage/users/latest', middleware, middlewares, controllers.admin.users.sortByJoinDate);
helpers.setupAdminPageRoute(app, '/admin/manage/users/not-validated', middleware, middlewares, controllers.admin.users.notValidated);
helpers.setupAdminPageRoute(app, '/admin/manage/users/no-posts', middleware, middlewares, controllers.admin.users.noPosts);
helpers.setupAdminPageRoute(app, '/admin/manage/users/top-posters', middleware, middlewares, controllers.admin.users.topPosters);
helpers.setupAdminPageRoute(app, '/admin/manage/users/most-reputation', middleware, middlewares, controllers.admin.users.mostReputaion);
helpers.setupAdminPageRoute(app, '/admin/manage/users/inactive', middleware, middlewares, controllers.admin.users.inactive);
helpers.setupAdminPageRoute(app, '/admin/manage/users/flagged', middleware, middlewares, controllers.admin.users.flagged);
helpers.setupAdminPageRoute(app, '/admin/manage/users/banned', middleware, middlewares, controllers.admin.users.banned);
helpers.setupAdminPageRoute(app, '/admin/manage/users', middleware, middlewares, controllers.admin.users.index);
helpers.setupAdminPageRoute(app, '/admin/manage/registration', middleware, middlewares, controllers.admin.users.registrationQueue);
helpers.setupAdminPageRoute(app, '/admin/manage/admins-mods', middleware, middlewares, controllers.admin.adminsMods.get);

@ -80,7 +80,10 @@ User.validateEmail = async function (socket, uids) {
uids = uids.filter(uid => parseInt(uid, 10));
await db.setObjectField(uids.map(uid => 'user:' + uid), 'email:confirmed', 1);
await db.sortedSetRemove('users:notvalidated', uids);
for (const uid of uids) {
await groups.join('verified-users', uid);
await groups.leave('unverified-users', uid);
}
};
User.sendValidationEmail = async function (socket, uids) {
@ -183,32 +186,6 @@ async function deleteUsers(socket, uids, method) {
}
}
User.search = async function (socket, data) {
// TODO: deprecate
const searchData = await user.search({
query: data.query,
searchBy: data.searchBy,
uid: socket.uid,
});
if (!searchData.users.length) {
return searchData;
}
const uids = searchData.users.map(user => user && user.uid);
const userInfo = await user.getUsersFields(uids, ['email', 'flags', 'lastonline', 'joindate']);
searchData.users.forEach(function (user, index) {
if (user && userInfo[index]) {
user.email = userInfo[index].email;
user.flags = userInfo[index].flags || 0;
user.lastonlineISO = userInfo[index].lastonlineISO;
user.joindateISO = userInfo[index].joindateISO;
}
});
return searchData;
};
User.restartJobs = async function () {
user.startJobs();
};

@ -30,7 +30,7 @@ SocketGroups.join = async (socket, data) => {
throw new Error('[[error:invalid-group-name]]');
}
if (data.groupName === 'administrators' || groups.isPrivilegeGroup(data.groupName)) {
if (groups.systemGroups.includes(data.groupName) || groups.isPrivilegeGroup(data.groupName)) {
throw new Error('[[error:not-allowed]]');
}

@ -6,6 +6,7 @@ const privileges = require('../../privileges');
module.exports = function (SocketUser) {
SocketUser.search = async function (socket, data) {
// TODO: depracate and use usersController.search
if (!data) {
throw new Error('[[error:invalid-data]]');
}
@ -14,7 +15,16 @@ module.exports = function (SocketUser) {
user.isPrivileged(socket.uid),
]);
if (!allowed || ((data.searchBy === 'ip' || data.searchBy === 'email' || data.bannedOnly || data.flaggedOnly) && !isPrivileged)) {
let filters = data.filters || [];
filters = Array.isArray(filters) ? filters : [filters];
if (!allowed ||
((
data.searchBy === 'ip' ||
data.searchBy === 'email' ||
filters.includes('banned') ||
filters.includes('flagged')
) && !isPrivileged)
) {
throw new Error('[[error:no-privileges]]');
}
const result = await user.search({
@ -22,10 +32,7 @@ module.exports = function (SocketUser) {
page: data.page,
searchBy: data.searchBy,
sortBy: data.sortBy,
onlineOnly: data.onlineOnly,
bannedOnly: data.bannedOnly,
notBanned: data.notBanned,
flaggedOnly: data.flaggedOnly,
filters: data.filters,
paginate: data.paginate,
uid: socket.uid,
});

@ -129,6 +129,7 @@ Upgrade.process = async function (files, skipCount) {
const version = path.dirname(file).split('/').pop();
const progress = {
current: 0,
counter: 0,
total: 0,
incr: Upgrade.incrementProgress,
script: scriptExport,
@ -177,9 +178,11 @@ Upgrade.incrementProgress = function (value) {
}
this.current += value || 1;
this.counter += value || 1;
const step = (this.total ? Math.floor(this.total / 100) : 100);
// Redraw the progress bar every 100 units
if (this.current % (this.total ? Math.floor(this.total / 100) : 100) === 0 || this.current === this.total) {
if (this.counter > step || this.current >= this.total) {
this.counter -= step;
var percentage = 0;
var filled = 0;
var unfilled = 15;

@ -0,0 +1,95 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
const user = require('../../user');
const groups = require('../../groups');
const meta = require('../../meta');
const privileges = require('../../privileges');
module.exports = {
name: 'Create verified/unverified user groups',
timestamp: Date.UTC(2020, 9, 13),
method: async function () {
const progress = this.progress;
const timestamp = await db.getObjectField('group:administrators', 'timestamp');
const verifiedExists = await groups.exists('verified-users');
if (!verifiedExists) {
await groups.create({
name: 'verified-users',
hidden: 1,
private: 1,
system: 1,
disableLeave: 1,
disableJoinRequests: 1,
timestamp: timestamp + 1,
});
}
const unverifiedExists = await groups.exists('unverified-users');
if (!unverifiedExists) {
await groups.create({
name: 'unverified-users',
hidden: 1,
private: 1,
system: 1,
disableLeave: 1,
disableJoinRequests: 1,
timestamp: timestamp + 1,
});
}
await batch.processSortedSet('users:joindate', async function (uids) {
progress.incr(uids.length);
const userData = await user.getUsersFields(uids, ['uid', 'email:confirmed']);
const verified = userData.filter(u => parseInt(u['email:confirmed'], 10) === 1);
const unverified = userData.filter(u => parseInt(u['email:confirmed'], 10) !== 1);
for (const user of verified) {
// eslint-disable-next-line no-await-in-loop
await groups.join('verified-users', user.uid);
}
for (const user of unverified) {
// eslint-disable-next-line no-await-in-loop
await groups.join('unverified-users', user.uid);
}
}, {
batch: 500,
progress: this.progress,
});
await db.delete('users:notvalidated');
await updatePrivilges();
},
};
async function updatePrivilges() {
// if email confirmation is required
// give chat, posting privs to "verified-users" group
// remove chat, posting privs from "registered-users" group
if (meta.config.requireEmailConfirmation) {
const cids = await db.getSortedSetRevRange('categories:cid', 0, -1);
const canChat = await privileges.global.canGroup('chat', 'registered-users');
if (canChat) {
await privileges.global.give(['groups:chat'], 'verified-users');
await privileges.global.rescind(['groups:chat'], 'registered-users');
}
for (const cid of cids) {
/* eslint-disable no-await-in-loop */
const data = await privileges.categories.list(cid);
const registeredUsersPrivs = data.groups.find(d => d.name === 'registered-users').privileges;
if (registeredUsersPrivs['groups:topics:create']) {
await privileges.categories.give(['groups:topics:create'], cid, 'verified-users');
await privileges.categories.rescind(['groups:topics:create'], cid, 'registered-users');
}
if (registeredUsersPrivs['groups:topics:reply']) {
await privileges.categories.give(['groups:topics:reply'], cid, 'verified-users');
await privileges.categories.rescind(['groups:topics:reply'], cid, 'registered-users');
}
}
}
}

@ -86,9 +86,6 @@ module.exports = function (User) {
['users:reputation', 0, userData.uid],
];
if (parseInt(userData.uid, 10) !== 1) {
bulkAdd.push(['users:notvalidated', timestamp, userData.uid]);
}
if (userData.email) {
bulkAdd.push(['email:uid', userData.uid, userData.email.toLowerCase()]);
bulkAdd.push(['email:sorted', 0, userData.email.toLowerCase() + ':' + userData.uid]);
@ -99,10 +96,15 @@ module.exports = function (User) {
bulkAdd.push(['fullname:sorted', 0, userData.fullname.toLowerCase() + ':' + userData.uid]);
}
const groupsToJoin = ['registered-users'].concat(
parseInt(userData.uid, 10) !== 1 ?
'unverified-users' : 'verified-users'
);
await Promise.all([
db.incrObjectField('global', 'userCount'),
db.sortedSetAddBulk(bulkAdd),
groups.join('registered-users', userData.uid),
groups.join(groupsToJoin, userData.uid),
User.notifications.sendWelcomeNotification(userData.uid),
storePassword(userData.uid, data.password),
User.updateDigestSetting(userData.uid, meta.config.dailyDigestFreq),

@ -81,7 +81,6 @@ module.exports = function (User) {
'users:banned:expire',
'users:flags',
'users:online',
'users:notvalidated',
'digest:day:uids',
'digest:week:uids',
'digest:month:uids',

@ -9,6 +9,7 @@ var plugins = require('../plugins');
var db = require('../database');
var meta = require('../meta');
var emailer = require('../emailer');
const groups = require('../groups');
var UserEmail = module.exports;
@ -96,8 +97,9 @@ UserEmail.confirm = async function (code) {
throw new Error('[[error:invalid-email]]');
}
await user.setUserField(confirmObj.uid, 'email:confirmed', 1);
await groups.join('verified-users', confirmObj.uid);
await groups.leave('unverified-users', confirmObj.uid);
await db.delete('confirm:' + code);
await db.delete('uid:' + confirmObj.uid + ':confirm:email:sent');
await db.sortedSetRemove('users:notvalidated', confirmObj.uid);
await plugins.fireHook('action:user.email.confirmed', { uid: confirmObj.uid, email: confirmObj.email });
};

@ -18,7 +18,7 @@ module.exports = function (User) {
return;
}
const [userData, isAdminOrMod] = await Promise.all([
User.getUserFields(uid, ['uid', 'banned', 'joindate', 'email', 'email:confirmed', 'reputation'].concat([field])),
User.getUserFields(uid, ['uid', 'banned', 'joindate', 'email', 'reputation'].concat([field])),
privileges.categories.isAdminOrMod(cid, uid),
]);
@ -34,16 +34,12 @@ module.exports = function (User) {
throw new Error('[[error:user-banned]]');
}
if (meta.config.requireEmailConfirmation && !userData['email:confirmed']) {
throw new Error('[[error:email-not-confirmed]]');
}
var now = Date.now();
const now = Date.now();
if (now - userData.joindate < meta.config.initialPostDelay * 1000) {
throw new Error('[[error:user-too-new, ' + meta.config.initialPostDelay + ']]');
}
var lasttime = userData[field] || 0;
const lasttime = userData[field] || 0;
if (meta.config.newbiePostDelay > 0 && meta.config.newbiePostDelayThreshold > userData.reputation && now - lasttime < meta.config.newbiePostDelay * 1000) {
throw new Error('[[error:too-many-posts-newbie, ' + meta.config.newbiePostDelay + ', ' + meta.config.newbiePostDelayThreshold + ']]');

@ -234,9 +234,10 @@ module.exports = function (User) {
['email:uid', uid, newEmail.toLowerCase()],
['email:sorted', 0, newEmail.toLowerCase() + ':' + uid],
['user:' + uid + ':emails', Date.now(), newEmail + ':' + Date.now()],
['users:notvalidated', Date.now(), uid],
]),
User.setUserFields(uid, { email: newEmail, 'email:confirmed': 0 }),
groups.leave('verified-users', uid),
groups.join('unverified-users', uid),
User.reset.cleanByUid(uid),
]);

@ -4,6 +4,7 @@ var nconf = require('nconf');
var winston = require('winston');
var user = require('./index');
const groups = require('../groups');
var utils = require('../utils');
var batch = require('../batch');
@ -70,11 +71,12 @@ UserReset.commit = async function (code, password) {
const hash = await user.hashPassword(password);
await user.setUserFields(uid, { password: hash, 'email:confirmed': 1 });
await groups.join('verified-users', uid);
await groups.leave('unverified-users', uid);
await db.deleteObjectField('reset:uid', code);
await db.sortedSetRemoveBulk([
['reset:issueDate', code],
['reset:issueDate:uid', uid],
['users:notvalidated', uid],
]);
await user.reset.updateExpiry(uid);
await user.auth.resetLockout(uid);

@ -10,6 +10,25 @@ const groups = require('../groups');
const utils = require('../utils');
module.exports = function (User) {
const filterFnMap = {
online: user => user.status !== 'offline' && (Date.now() - user.lastonline < 300000),
banned: user => user.banned,
notbanned: user => !user.banned,
flagged: user => parseInt(user.flags, 10) > 0,
verified: user => !!user['email:confirmed'],
unverified: user => !user['email:confirmed'],
};
const filterFieldMap = {
online: ['status', 'lastonline'],
banned: ['banned'],
notbanned: ['banned'],
flagged: ['flags'],
verified: ['email:confirmed'],
unverified: ['email:confirmed'],
};
User.search = async function (data) {
const query = data.query || '';
const searchBy = data.searchBy || 'username';
@ -69,21 +88,19 @@ module.exports = function (User) {
async function filterAndSortUids(uids, data) {
uids = uids.filter(uid => parseInt(uid, 10));
let filters = data.filters || [];
filters = Array.isArray(filters) ? filters : [data.filters];
const fields = [];
if (data.sortBy) {
fields.push(data.sortBy);
}
if (data.onlineOnly) {
fields.push('status', 'lastonline');
}
if (data.bannedOnly || data.notBanned) {
fields.push('banned');
}
if (data.flaggedOnly) {
fields.push('flags');
}
filters.forEach(function (filter) {
if (filterFieldMap[filter]) {
fields.push(...filterFieldMap[filter]);
}
});
if (data.groupName) {
const isMembers = await groups.isMembers(uids, data.groupName);
@ -96,42 +113,36 @@ module.exports = function (User) {
fields.push('uid');
let userData = await User.getUsersFields(uids, fields);
if (data.onlineOnly) {
userData = userData.filter(user => user.status !== 'offline' && (Date.now() - user.lastonline < 300000));
}
if (data.bannedOnly) {
userData = userData.filter(user => user.banned);
}
if (data.notBanned) {
userData = userData.filter(user => !user.banned);
}
if (data.flaggedOnly) {
userData = userData.filter(user => parseInt(user.flags, 10) > 0);
}
filters.forEach(function (filter) {
if (filterFnMap[filter]) {
userData = userData.filter(filterFnMap[filter]);
}
});
if (data.sortBy) {
sortUsers(userData, data.sortBy);
sortUsers(userData, data.sortBy, data.sortDirection);
}
return userData.map(user => user.uid);
}
function sortUsers(userData, sortBy) {
function sortUsers(userData, sortBy, sortDirection) {
if (!userData || !userData.length) {
return;
}
sortDirection = sortDirection || 'desc';
const direction = sortDirection === 'desc' ? 1 : -1;
const isNumeric = utils.isNumber(userData[0][sortBy]);
if (isNumeric) {
userData.sort((u1, u2) => u2[sortBy] - u1[sortBy]);
userData.sort((u1, u2) => direction * (u2[sortBy] - u1[sortBy]));
} else {
userData.sort(function (u1, u2) {
if (u1[sortBy] < u2[sortBy]) {
return -1;
return direction * -1;
} else if (u1[sortBy] > u2[sortBy]) {
return 1;
return direction * 1;
}
return 0;
});

@ -2,51 +2,70 @@
<div class="col-lg-12">
<div class="clearfix">
<form class="form-inline pull-right">
<button id="createUser" class="btn btn-primary">[[admin/manage/users:new]]</button>
<!-- IF inviteOnly -->
<button component="user/invite" class="btn btn-success"><i class="fa fa-users"></i> [[admin/manage/users:invite]]</button>
<!-- ENDIF inviteOnly -->
<a target="_blank" href="{config.relative_path}/api/admin/users/csv" class="btn btn-primary">[[admin/manage/users:download-csv]]</a>
<div class="btn-group">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" type="button">[[admin/manage/users:edit]] <span class="caret"></span></button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="#" class="validate-email"><i class="fa fa-fw fa-check"></i> [[admin/manage/users:validate-email]]</a></li>
<li><a href="#" class="send-validation-email"><i class="fa fa-fw fa-mail-forward"></i> [[admin/manage/users:send-validation-email]]</a></li>
<li><a href="#" class="password-reset-email"><i class="fa fa-fw fa-key"></i> [[admin/manage/users:password-reset-email]]</a></li>
<li><a href="#" class="force-password-reset"><i class="fa fa-fw fa-unlock-alt"></i> [[admin/manage/users:force-password-reset]]</a></li>
<li><a href="#" class="manage-groups"><i class="fa fa-fw fa-users"></i> [[admin/manage/users:manage-groups]]</a></li>
<li class="divider"></li>
<li><a href="#" class="ban-user"><i class="fa fa-fw fa-gavel"></i> [[admin/manage/users:ban]]</a></li>
<li><a href="#" class="ban-user-temporary"><i class="fa fa-fw fa-clock-o"></i> [[admin/manage/users:temp-ban]]</a></li>
<li><a href="#" class="unban-user"><i class="fa fa-fw fa-comment-o"></i> [[admin/manage/users:unban]]</a></li>
<li><a href="#" class="reset-lockout"><i class="fa fa-fw fa-unlock"></i> [[admin/manage/users:reset-lockout]]</a></li>
<li class="divider"></li>
<li><a href="#" class="delete-user"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:delete]]</a></li>
<li><a href="#" class="delete-user-content"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:delete-content]]</a></li>
<li><a href="#" class="delete-user-and-content"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:purge]]</a></li>
</ul>
<div class="input-group">
<input type="text" class="form-control" placeholder="[[global:search]]" id="user-search" value="{query}">
<span class="input-group-addon search-button"><i class="fa fa-search"></i></span>
</div>
<select id="user-search-by" class="form-control">
<option value="username" {{{if searchBy_username}}}selected{{{end}}}>[[admin/manage/users:search.username]]</option>
<option value="email" {{{if searchBy_email}}}selected{{{end}}}>[[admin/manage/users:search.email]]</option>
<option value="uid" {{{if searchBy_uid}}}selected{{{end}}}>[[admin/manage/users:search.uid]]</option>
<option value="ip" {{{if searchBy_ip}}}selected{{{end}}}>[[admin/manage/users:search.ip]]</option>
</select>
<select id="results-per-page" class="form-control">
<option value="50">[[admin/manage/users:50-per-page]]</option>
<option value="100">[[admin/manage/users:100-per-page]]</option>
<option value="250">[[admin/manage/users:250-per-page]]</option>
<option value="500">[[admin/manage/users:500-per-page]]</option>
</select>
</form>
</div>
<hr/>
<ul class="nav nav-pills">
<li><a href='{config.relative_path}/admin/manage/users/latest?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.latest]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/not-validated?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.unvalidated]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/no-posts?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.no-posts]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/top-posters?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.top-posters]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/most-reputation?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.top-rep]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/inactive?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.inactive]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/flagged?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.flagged]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/banned?resultsPerPage={resultsPerPage}'>[[admin/manage/users:pills.banned]]</a></li>
<li><a href='{config.relative_path}/admin/manage/users/search'>[[admin/manage/users:pills.search]]</a></li>
<li class="pull-right">
<div class="btn-group" id="filter-by">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
[[admin/manage/users:filter-by]]<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li data-filter-by="unverified" role="presentation">
<a role="menu-item" href="#"><i class="fa fa-fw {{{ if filterBy_unverified }}}fa-check{{{end}}}"></i>[[admin/manage/users:pills.unvalidated]]</a>
</li>
<li data-filter-by="verified" role="presentation">
<a role="menu-item" href="#"><i class="fa fa-fw {{{ if filterBy_verified }}}fa-check{{{end}}}"></i>[[admin/manage/users:pills.validated]]</a>
</li>
<li data-filter-by="banned" role="presentation">
<a role="menu-item" href="#"><i class="fa fa-fw {{{ if filterBy_banned }}}fa-check{{{end}}}"></i>[[admin/manage/users:pills.banned]]</a>
</li>
</ul>
</div>
</li>
<li class="pull-right">
<form class="form-inline">
<select id="results-per-page" class="form-control">
<option value="50">[[admin/manage/users:50-per-page]]</option>
<option value="100">[[admin/manage/users:100-per-page]]</option>
<option value="250">[[admin/manage/users:250-per-page]]</option>
<option value="500">[[admin/manage/users:500-per-page]]</option>
</select>
<!-- IF inviteOnly -->
<button component="user/invite" class="btn btn-success"><i class="fa fa-users"></i> [[admin/manage/users:invite]]</button>
<!-- ENDIF inviteOnly -->
<a target="_blank" href="{config.relative_path}/api/admin/users/csv" class="btn btn-primary">[[admin/manage/users:download-csv]]</a>
<div class="btn-group">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" type="button">[[admin/manage/users:edit]] <span class="caret"></span></button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="#" class="validate-email"><i class="fa fa-fw fa-check"></i> [[admin/manage/users:validate-email]]</a></li>
<li><a href="#" class="send-validation-email"><i class="fa fa-fw fa-mail-forward"></i> [[admin/manage/users:send-validation-email]]</a></li>
<li><a href="#" class="password-reset-email"><i class="fa fa-fw fa-key"></i> [[admin/manage/users:password-reset-email]]</a></li>
<li><a href="#" class="force-password-reset"><i class="fa fa-fw fa-unlock-alt"></i> [[admin/manage/users:force-password-reset]]</a></li>
<li><a href="#" class="manage-groups"><i class="fa fa-fw fa-users"></i> [[admin/manage/users:manage-groups]]</a></li>
<li class="divider"></li>
<li><a href="#" class="ban-user"><i class="fa fa-fw fa-gavel"></i> [[admin/manage/users:ban]]</a></li>
<li><a href="#" class="ban-user-temporary"><i class="fa fa-fw fa-clock-o"></i> [[admin/manage/users:temp-ban]]</a></li>
<li><a href="#" class="unban-user"><i class="fa fa-fw fa-comment-o"></i> [[admin/manage/users:unban]]</a></li>
<li><a href="#" class="reset-lockout"><i class="fa fa-fw fa-unlock"></i> [[admin/manage/users:reset-lockout]]</a></li>
<li class="divider"></li>
<li><a href="#" class="delete-user"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:delete]]</a></li>
<li><a href="#" class="delete-user-content"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:delete-content]]</a></li>
<li><a href="#" class="delete-user-and-content"><i class="fa fa-fw fa-trash-o"></i> [[admin/manage/users:purge]]</a></li>
</ul>
</div>
</form>
</li>
</ul>
@ -54,24 +73,6 @@
<br />
<div class="search {search_display}">
<form class="form-inline">
<div class="form-group">
<label>[[admin/manage/users:search.uid]]</label>
<input class="form-control" id="search-user-uid" data-search-type="uid" type="number" placeholder="[[admin/manage/users:search.uid-placeholder]]" value="{uidQuery}"/>
</div>
<div class="form-group">
<label>[[admin/manage/users:search.username]]</label>
<input class="form-control" id="search-user-name" data-search-type="username" type="text" placeholder="[[admin/manage/users:search.username-placeholder]]" value="{usernameQuery}"/>
</div>
<div class="form-group">
<label>[[admin/manage/users:search.email]]</label>
<input class="form-control" id="search-user-email" data-search-type="email" type="text" placeholder="[[admin/manage/users:search.email-placeholder]]" value="{emailQuery}"/>
</div>
<div class="form-group">
<label>[[admin/manage/users:search.ip]]</label>
<input class="form-control" id="search-user-ip" data-search-type="ip" type="text" placeholder="[[admin/manage/users:search.ip-placeholder]]" value="{ipQuery}"/>
</div>
</form>
<i class="fa fa-spinner fa-spin hidden"></i>
<div id="user-found-notify" class="label label-info {{{if !matchCount}}}hidden{{{end}}}">[[admin/manage/users:alerts.x-users-found, {matchCount}, {timing}]]</div>
@ -90,15 +91,14 @@
<thead>
<tr>
<th><input component="user/select/all" type="checkbox"/></th>
<th class="text-right">[[admin/manage/users:users.uid]]</th>
<th>[[admin/manage/users:users.username]]</th>
<th>[[admin/manage/users:users.email]]</th>
<th class="text-right">[[admin/manage/users:users.postcount]]</th>
<th class="text-right">[[admin/manage/users:users.reputation]]</th>
<th class="text-right">[[admin/manage/users:users.flags]]</th>
<th>[[admin/manage/users:users.joined]]</th>
<th>[[admin/manage/users:users.last-online]]</th>
<th>[[admin/manage/users:users.banned]]</th>
<th class="text-right text-muted">[[admin/manage/users:users.uid]]</th>
<th class="text-muted">[[admin/manage/users:users.username]]</th>
<th class="text-muted">[[admin/manage/users:users.email]]</th>
<th data-sort="postcount" class="text-right pointer">[[admin/manage/users:users.postcount]] {{{if sort_postcount}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="reputation" class="text-right pointer">[[admin/manage/users:users.reputation]] {{{if sort_reputation}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="flags" class="text-right pointer">[[admin/manage/users:users.flags]] {{{if sort_flags}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="joindate" class="pointer">[[admin/manage/users:users.joined]] {{{if sort_joindate}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="online" class="pointer">[[admin/manage/users:users.last-online]] {{{if sort_online}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
</tr>
</thead>
<tbody>
@ -106,19 +106,17 @@
<tr class="user-row">
<th><input component="user/select/single" data-uid="{users.uid}" type="checkbox"/></th>
<td class="text-right">{users.uid}</td>
<td><i class="administrator fa fa-shield text-success<!-- IF !users.administrator --> hidden<!-- ENDIF !users.administrator -->"></i><a href="{config.relative_path}/user/{users.userslug}"> {users.username}</a></td>
<td><i title="[[admin/manage/users:users.banned]]" class="ban fa fa-gavel text-danger<!-- IF !users.banned --> hidden<!-- ENDIF !users.banned -->"></i><i class="administrator fa fa-shield text-success<!-- IF !users.administrator --> hidden<!-- ENDIF !users.administrator -->"></i><a href="{config.relative_path}/user/{users.userslug}"> {users.username}</a></td>
<td>
<!-- IF config.requireEmailConfirmation -->
<i class="validated fa fa-check text-success<!-- IF !users.email:confirmed --> hidden<!-- ENDIF !users.email:confirmed -->" title="validated"></i>
<i class="notvalidated fa fa-times text-danger<!-- IF users.email:confirmed --> hidden<!-- ENDIF users.email:confirmed -->" title="not validated"></i>
<!-- ENDIF config.requireEmailConfirmation --> {users.email}</td>
<i class="notvalidated fa fa-check text-muted<!-- IF users.email:confirmed --> hidden<!-- ENDIF users.email:confirmed -->" title="not validated"></i>
{users.email}</td>
<td class="text-right">{users.postcount}</td>
<td class="text-right">{users.reputation}</td>
<td class="text-right"><!-- IF users.flags -->{users.flags}<!-- ELSE -->0<!-- ENDIF users.flags --></td>
<td><span class="timeago" title="{users.joindateISO}"></span></td>
<td><span class="timeago" title="{users.lastonlineISO}"></span></td>
<td class="text-center"><i class="ban fa fa-gavel text-danger<!-- IF !users.banned --> hidden<!-- ENDIF !users.banned -->"></i></td>
</tr>
<!-- END users -->
</tbody>
@ -129,3 +127,7 @@
</div>
</div>
<button data-action="create" class="floating-button mdl-button mdl-js-button mdl-button--fab mdl-js-ripple-effect mdl-button--colored">
<i class="material-icons">add</i>
</button>

@ -1,4 +1,4 @@
<div class="alert alert-danger d-none" id="create-modal-error"></div>
<div class="alert alert-danger hidden" id="create-modal-error"></div>
<form>
<div class="form-group">
<label for="group-name">[[admin/manage/users:create.username]]</label>

@ -219,78 +219,6 @@ describe('Admin Controllers', function () {
});
});
it('should load /admin/manage/users/search', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/search', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body.users);
done();
});
});
it('should load /admin/manage/users/not-validated', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/not-validated', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
done();
});
});
it('should load /admin/manage/users/no-posts', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/no-posts', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
done();
});
});
it('should load /admin/manage/users/top-posters', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/top-posters', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
done();
});
});
it('should load /admin/manage/users/most-reputation', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/most-reputation', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
done();
});
});
it('should load /admin/manage/users/inactive', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/inactive', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
done();
});
});
it('should load /admin/manage/users/flagged', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/flagged', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
done();
});
});
it('should load /admin/manage/users/banned', function (done) {
request(nconf.get('url') + '/api/admin/manage/users/banned', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
done();
});
});
it('should load /admin/manage/registration', function (done) {
request(nconf.get('url') + '/api/admin/manage/registration', { jar: jar, json: true }, function (err, res, body) {
assert.ifError(err);

@ -661,7 +661,7 @@ describe('Controllers', function () {
});
it('should error if guests do not have search privilege', function (done) {
request(nconf.get('url') + '/api/users?term=bar&section=sort-posts', { json: true }, function (err, res, body) {
request(nconf.get('url') + '/api/users?query=bar&section=sort-posts', { json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 500);
assert(body);
@ -673,7 +673,7 @@ describe('Controllers', function () {
it('should load users search page', function (done) {
privileges.global.give(['groups:search:users'], 'guests', function (err) {
assert.ifError(err);
request(nconf.get('url') + '/users?term=bar&section=sort-posts', function (err, res, body) {
request(nconf.get('url') + '/users?query=bar&section=sort-posts', function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
assert(body);
@ -1509,6 +1509,7 @@ describe('Controllers', function () {
assert.ifError(err);
request(nconf.get('url') + '/api/user/groupie', { json: true }, function (err, res, body) {
assert.ifError(err);
console.log(body);
assert.equal(res.statusCode, 200);
assert(Array.isArray(body.selectedGroup));
assert.equal(body.selectedGroup[0].name, 'selectedGroup');

@ -48,6 +48,16 @@ describe('Groups', function () {
disableLeave: 1,
});
},
async () => {
await Groups.create({
name: 'Global Moderators',
userTitle: 'Global Moderator',
description: 'Forum wide moderators',
hidden: 0,
private: 1,
disableJoinRequests: 1,
});
},
function (next) {
// Create a new user
User.create({
@ -72,8 +82,8 @@ describe('Groups', function () {
},
], function (err, results) {
assert.ifError(err);
testUid = results[4];
adminUid = results[5];
testUid = results[5];
adminUid = results[6];
Groups.join('administrators', adminUid, done);
});
});
@ -699,6 +709,29 @@ describe('Groups', function () {
});
});
});
it('should fail to add user to system group', async function () {
const uid = await User.create({ username: 'eviluser' });
const oldValue = meta.config.allowPrivateGroups;
meta.config.allowPrivateGroups = 0;
async function test(groupName) {
let err;
try {
await socketGroups.join({ uid: uid }, { groupName: groupName });
const isMember = await Groups.isMember(uid, groupName);
assert.strictEqual(isMember, false);
} catch (_err) {
err = _err;
}
assert.strictEqual(err.message, '[[error:not-allowed]]');
}
const groups = ['Global Moderators', 'verified-users', 'unverified-users'];
for (const g of groups) {
// eslint-disable-next-line no-await-in-loop
await test(g);
}
meta.config.allowPrivateGroups = oldValue;
});
});
describe('.leave()', function () {

@ -277,15 +277,6 @@ describe('socket.io', function () {
});
});
it('should search users', function (done) {
socketAdmin.user.search({ uid: adminUid }, { query: 'reg', searchBy: 'username' }, function (err, data) {
assert.ifError(err);
assert.equal(data.matchCount, 1);
assert.equal(data.users[0].username, 'regular');
done();
});
});
it('should push unread notifications on reconnect', function (done) {
var socketMeta = require('../src/socket.io/meta');
socketMeta.reconnected({ uid: 1 }, {}, function (err) {

@ -369,14 +369,14 @@ describe('User', function () {
});
it('should error for unprivileged user', function (done) {
socketUser.search({ uid: testUid }, { bannedOnly: true, query: '123' }, function (err) {
socketUser.search({ uid: testUid }, { filters: ['banned'], query: '123' }, function (err) {
assert.equal(err.message, '[[error:no-privileges]]');
done();
});
});
it('should error for unprivileged user', function (done) {
socketUser.search({ uid: testUid }, { flaggedOnly: true, query: '123' }, function (err) {
socketUser.search({ uid: testUid }, { filters: ['flagged'], query: '123' }, function (err) {
assert.equal(err.message, '[[error:no-privileges]]');
done();
});
@ -430,9 +430,7 @@ describe('User', function () {
assert.ifError(err);
socketUser.search({ uid: adminUid }, {
query: 'ipsearch',
onlineOnly: true,
bannedOnly: true,
flaggedOnly: true,
filters: ['online', 'banned', 'flagged'],
}, function (err, data) {
assert.ifError(err);
assert.equal(data.users[0].username, 'ipsearch_filter');
@ -2073,34 +2071,23 @@ describe('User', function () {
});
});
it('should confirm email of user', function (done) {
var email = 'confirm@me.com';
User.create({
it('should confirm email of user', async function () {
const email = 'confirm@me.com';
const uid = await User.create({
username: 'confirme',
email: email,
}, function (err, uid) {
assert.ifError(err);
User.email.sendValidationEmail(uid, email, function (err, code) {
assert.ifError(err);
User.email.confirm(code, function (err) {
assert.ifError(err);
async.parallel({
confirmed: function (next) {
db.getObjectField('user:' + uid, 'email:confirmed', next);
},
isMember: function (next) {
db.isSortedSetMember('users:notvalidated', uid, next);
},
}, function (err, results) {
assert.ifError(err);
assert.equal(results.confirmed, 1);
assert.equal(results.isMember, false);
done();
});
});
});
});
const code = await User.email.sendValidationEmail(uid, email);
const unverified = await groups.isMember(uid, 'unverified-users');
assert.strictEqual(unverified, true);
await User.email.confirm(code);
const [confirmed, isVerified] = await Promise.all([
db.getObjectField('user:' + uid, 'email:confirmed'),
groups.isMember(uid, 'verified-users', uid),
]);
assert.strictEqual(parseInt(confirmed, 10), 1);
assert.strictEqual(isVerified, true);
});
});

Loading…
Cancel
Save