feat: invites regardless of registration type, invite privilege, groups to join on acceptance (#8786)

* feat: allow invites in normal registration mode + invite privilege

* feat: select groups to join from an invite

* test: check if groups from invitations have been joined

* fix: remove unused variable

* feat: write API versions of socket calls

* docs: openapi specs for the new routes

* test: iron out mongo redis difference

* refactor: move inviteGroups endpoint into write API

* refactor: use GET /api/v3/users/:uid/invites/groups

Instead of GET /api/v3/users/:uid/inviteGroups

* fix: no need for /api/v3 prefix when using api module

* fix: tests

* refactor: change POST /api/v3/users/invite

To POST /api/v3/users/:uid/invites

* refactor: make helpers.invite awaitable

* fix: restrict invite API to self-use only

* fix: move invite groups controller to write api, +tests

* fix: tests

Co-authored-by: Julian Lam <julian@nodebb.org>
This commit is contained in:
gasoved
2020-11-16 22:47:23 +03:00
committed by GitHub
parent dde9f1890f
commit 3ccebf112e
23 changed files with 725 additions and 220 deletions

View File

@@ -9,6 +9,7 @@ const db = require('../../database');
const pagination = require('../../pagination');
const events = require('../../events');
const plugins = require('../../plugins');
const privileges = require('../../privileges');
const utils = require('../../utils');
const usersController = module.exports;
@@ -115,7 +116,7 @@ async function getUsers(req, res) {
getUsersWithFields(set),
]);
render(req, res, {
await render(req, res, {
users: users.filter(user => user && parseInt(user.uid, 10)),
page: page,
pageCount: Math.max(1, Math.ceil(count / resultsPerPage)),
@@ -176,7 +177,7 @@ usersController.search = async function (req, res) {
searchData.resultsPerPage = resultsPerPage;
searchData.sortBy = req.query.sortBy;
searchData.reverse = reverse;
render(req, res, searchData);
await render(req, res, searchData);
};
usersController.registrationQueue = async function (req, res) {
@@ -226,7 +227,7 @@ async function getInvites() {
return invitations;
}
function render(req, res, data) {
async function render(req, res, data) {
data.pagination = pagination.create(data.page, data.pageCount, req.query);
const registrationType = meta.config.registrationType;
@@ -241,6 +242,12 @@ function render(req, res, data) {
filterBy.forEach(function (filter) {
data['filterBy_' + validator.escape(String(filter))] = true;
});
data.showInviteButton = await privileges.users.hasInvitePrivilege(req.uid);
if (data.adminInviteOnly) {
data.showInviteButton = await privileges.users.isAdministrator(req.uid);
}
res.render('admin/manage/users', data);
}

View File

@@ -55,7 +55,11 @@ async function registerAndLoginUser(req, res, userData) {
await authenticationController.doLogin(req, uid);
}
user.deleteInvitationKey(userData.email);
// Distinguish registrations through invites from direct ones
if (userData.token) {
await user.joinGroupsFromInvitation(uid, userData.email);
}
await user.deleteInvitationKey(userData.email);
const referrer = req.body.referrer || req.session.referrer || nconf.get('relative_path') + '/';
const complete = await plugins.fireHook('filter:register.complete', { uid: uid, referrer: referrer });
req.session.returnTo = complete.referrer;
@@ -74,7 +78,7 @@ authenticationController.register = async function (req, res) {
const userData = req.body;
try {
if (registrationType === 'invite-only' || registrationType === 'admin-invite-only') {
if (userData.token || registrationType === 'invite-only' || registrationType === 'admin-invite-only') {
await user.verifyInvitation(userData);
}

View File

@@ -96,7 +96,7 @@ async function renderIfAdminOrGlobalMod(set, req, res) {
usersController.renderUsersPage = async function (set, req, res) {
const userData = await usersController.getUsers(set, req.uid, req.query);
render(req, res, userData);
await render(req, res, userData);
};
usersController.getUsers = async function (set, uid, query) {
@@ -171,10 +171,15 @@ async function render(req, res, data) {
data.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only';
data.adminInviteOnly = registrationType === 'admin-invite-only';
data.invites = await user.getInvitesNumber(req.uid);
data.showInviteButton = req.loggedIn && (
(registrationType === 'invite-only' && (data.isAdmin || !data.maximumInvites || data.invites < data.maximumInvites)) ||
(registrationType === 'admin-invite-only' && data.isAdmin)
);
data.showInviteButton = false;
if (data.adminInviteOnly) {
data.showInviteButton = await privileges.users.isAdministrator(req.uid);
} else if (req.loggedIn) {
const canInvite = await privileges.users.hasInvitePrivilege(req.uid);
data.showInviteButton = canInvite && (!data.maximumInvites || data.invites < data.maximumInvites);
}
data['reputation:disabled'] = meta.config['reputation:disabled'];
res.append('X-Total-Count', data.userCount);

View File

@@ -5,9 +5,10 @@ const nconf = require('nconf');
const db = require('../../database');
const api = require('../../api');
const user = require('../../user');
const groups = require('../../groups');
const meta = require('../../meta');
const privileges = require('../../privileges');
const user = require('../../user');
const utils = require('../../utils');
const helpers = require('../helpers');
@@ -153,3 +154,60 @@ Users.revokeSession = async (req, res) => {
await user.auth.revokeSession(_id, req.params.uid);
helpers.formatApiResponse(200, res);
};
Users.invite = async (req, res) => {
const { emails, groupsToJoin = [] } = req.body;
if (!emails || !Array.isArray(groupsToJoin)) {
return helpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]'));
}
// For simplicity, this API route is restricted to self-use only. This can change if needed.
if (parseInt(req.user.uid, 10) !== parseInt(req.params.uid, 10)) {
return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]'));
}
const canInvite = await privileges.users.hasInvitePrivilege(req.uid);
if (!canInvite) {
return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]'));
}
const registrationType = meta.config.registrationType;
const isAdmin = await user.isAdministrator(req.uid);
if (registrationType === 'admin-invite-only' && !isAdmin) {
return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]'));
}
const inviteGroups = await groups.getUserInviteGroups(req.uid);
const cannotInvite = groupsToJoin.some(group => !inviteGroups.includes(group));
if (groupsToJoin.length > 0 && cannotInvite) {
return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]'));
}
const max = meta.config.maximumInvites;
const emailsArr = emails.split(',').map(email => email.trim()).filter(Boolean);
for (const email of emailsArr) {
/* eslint-disable no-await-in-loop */
let invites = 0;
if (max) {
invites = await user.getInvitesNumber(req.uid);
}
if (!isAdmin && max && invites >= max) {
return helpers.formatApiResponse(403, res, new Error('[[error:invite-maximum-met, ' + invites + ', ' + max + ']]'));
}
await user.sendInvitationEmail(req.uid, email, groupsToJoin);
}
return helpers.formatApiResponse(200, res);
};
Users.getInviteGroups = async function (req, res) {
if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10)) {
return helpers.formatApiResponse(401, res);
}
const userInviteGroups = await groups.getUserInviteGroups(req.params.uid);
return helpers.formatApiResponse(200, res, userInviteGroups);
};

View File

@@ -31,4 +31,34 @@ module.exports = function (Groups) {
const isMembers = await Groups.isMemberOfGroups(uid, groupNames);
return groupNames.filter((name, i) => isMembers[i]);
}
Groups.getUserInviteGroups = async function (uid) {
let allGroups = await Groups.getNonPrivilegeGroups('groups:createtime', 0, -1);
allGroups = allGroups.filter(group => !Groups.ephemeralGroups.includes(group.name));
const publicGroups = allGroups.filter(group => group.hidden === 0 && group.system === 0 && group.private === 0);
const adminModGroups = [{ name: 'administrators' }, { name: 'Global Moderators' }];
// Private (but not hidden)
const privateGroups = allGroups.filter(group => group.hidden === 0 && group.system === 0 && group.private === 1);
const [ownership, isAdmin, isGlobalMod] = await Promise.all([
Promise.all(privateGroups.map(group => Groups.ownership.isOwner(uid, group.name))),
user.isAdministrator(uid),
user.isGlobalModerator(uid),
]);
const ownGroups = privateGroups.filter((group, index) => ownership[index]);
let inviteGroups = [];
if (isAdmin) {
inviteGroups = inviteGroups.concat(adminModGroups).concat(privateGroups);
} else if (isGlobalMod) {
inviteGroups = inviteGroups.concat(privateGroups);
} else {
inviteGroups = inviteGroups.concat(ownGroups);
}
return inviteGroups
.concat(publicGroups)
.map(group => group.name);
};
};

View File

@@ -18,6 +18,7 @@ module.exports = function (privileges) {
{ name: '[[admin/manage/privileges:upload-files]]' },
{ name: '[[admin/manage/privileges:signature]]' },
{ name: '[[admin/manage/privileges:ban]]' },
{ name: '[[admin/manage/privileges:invite]]' },
{ name: '[[admin/manage/privileges:search-content]]' },
{ name: '[[admin/manage/privileges:search-users]]' },
{ name: '[[admin/manage/privileges:search-tags]]' },
@@ -35,6 +36,7 @@ module.exports = function (privileges) {
'upload:post:file',
'signature',
'ban',
'invite',
'search:content',
'search:users',
'search:tags',

View File

@@ -115,4 +115,13 @@ module.exports = function (privileges) {
});
return data.canBan;
};
privileges.users.hasInvitePrivilege = async function (uid) {
const canInvite = await privileges.global.can('invite', uid);
const data = await plugins.fireHook('filter:user.hasInvitePrivilege', {
uid: uid,
canInvite: canInvite,
});
return data.canInvite;
};
};

View File

@@ -39,6 +39,9 @@ function authenticatedRoutes() {
// Shorthand route to access user routes by userslug
router.all('/+bySlug/:userslug*?', [], controllers.write.users.redirectBySlug);
setupApiRoute(router, 'post', '/:uid/invites', middlewares, controllers.write.users.invite);
setupApiRoute(router, 'get', '/:uid/invites/groups', [...middlewares, middleware.assert.user], controllers.write.users.getInviteGroups);
}
module.exports = function () {

View File

@@ -1,7 +1,5 @@
'use strict';
const async = require('async');
const util = require('util');
const sleep = util.promisify(setTimeout);
@@ -223,37 +221,6 @@ SocketUser.getUnreadCounts = async function (socket) {
return results;
};
SocketUser.invite = async function (socket, email) {
if (!email || !socket.uid) {
throw new Error('[[error:invalid-data]]');
}
const registrationType = meta.config.registrationType;
if (registrationType !== 'invite-only' && registrationType !== 'admin-invite-only') {
throw new Error('[[error:forum-not-invite-only]]');
}
const isAdmin = await user.isAdministrator(socket.uid);
if (registrationType === 'admin-invite-only' && !isAdmin) {
throw new Error('[[error:no-privileges]]');
}
const max = meta.config.maximumInvites;
email = email.split(',').map(email => email.trim()).filter(Boolean);
await async.eachSeries(email, async function (email) {
let invites = 0;
if (max) {
invites = await user.getInvitesNumber(socket.uid);
}
if (!isAdmin && max && invites >= max) {
throw new Error('[[error:invite-maximum-met, ' + invites + ', ' + max + ']]');
}
await user.sendInvitationEmail(socket.uid, email);
});
};
SocketUser.getUserByUID = async function (socket, uid) {
return await userController.getUserDataByField(socket.uid, 'uid', uid);
};

View File

@@ -8,6 +8,7 @@ var validator = require('validator');
var db = require('../database');
var meta = require('../meta');
var emailer = require('../emailer');
var groups = require('../groups');
var translator = require('../translator');
var utils = require('../utils');
@@ -36,13 +37,7 @@ module.exports = function (User) {
});
};
User.sendInvitationEmail = async function (uid, email) {
const token = utils.generateUUID();
const registerLink = nconf.get('url') + '/register?token=' + token + '&email=' + encodeURIComponent(email);
const expireDays = meta.config.inviteExpiration;
const expireIn = expireDays * 86400000;
User.sendInvitationEmail = async function (uid, email, groupsToJoin) {
const email_exists = await User.getUidByEmail(email);
if (email_exists) {
throw new Error('[[error:email-taken]]');
@@ -53,24 +48,7 @@ module.exports = function (User) {
throw new Error('[[error:email-invited]]');
}
await db.setAdd('invitation:uid:' + uid, email);
await db.setAdd('invitation:uids', uid);
await db.set('invitation:email:' + email, token);
await db.pexpireAt('invitation:email:' + email, Date.now() + expireIn);
const username = await User.getUserField(uid, 'username');
const title = meta.config.title || meta.config.browserTitle || 'NodeBB';
const subject = await translator.translate('[[email:invite, ' + title + ']]', meta.config.defaultLang);
let data = {
site_title: title,
registerLink: registerLink,
subject: subject,
username: username,
template: 'invitation',
expireDays: expireDays,
};
// Append default data to this email payload
data = { ...emailer._defaultPayload, ...data };
const data = await prepareInvitation(uid, email, groupsToJoin);
await emailer.sendToEmail('invitation', email, meta.config.defaultLang, data);
};
@@ -79,12 +57,28 @@ module.exports = function (User) {
if (!query.token || !query.email) {
throw new Error('[[error:invalid-data]]');
}
const token = await db.get('invitation:email:' + query.email);
const token = await db.getObjectField('invitation:email:' + query.email, 'token');
if (!token || token !== query.token) {
throw new Error('[[error:invalid-token]]');
}
};
User.joinGroupsFromInvitation = async function (uid, email) {
let groupsToJoin = await db.getObjectField('invitation:email:' + email, 'groupsToJoin');
try {
groupsToJoin = JSON.parse(groupsToJoin);
} catch (e) {
return;
}
if (!groupsToJoin || groupsToJoin.length < 1) {
return;
}
await groups.join(groupsToJoin, uid);
};
User.deleteInvitation = async function (invitedBy, email) {
const invitedByUid = await User.getUidByUsername(invitedBy);
if (!invitedByUid) {
@@ -109,4 +103,34 @@ module.exports = function (User) {
await db.setRemove('invitation:uids', uid);
}
}
async function prepareInvitation(uid, email, groupsToJoin) {
const token = utils.generateUUID();
const registerLink = nconf.get('url') + '/register?token=' + token + '&email=' + encodeURIComponent(email);
const expireDays = meta.config.inviteExpiration;
const expireIn = expireDays * 86400000;
await db.setAdd('invitation:uid:' + uid, email);
await db.setAdd('invitation:uids', uid);
await db.setObject('invitation:email:' + email, {
token,
groupsToJoin: JSON.stringify(groupsToJoin),
});
await db.pexpireAt('invitation:email:' + email, Date.now() + expireIn);
const username = await User.getUserField(uid, 'username');
const title = meta.config.title || meta.config.browserTitle || 'NodeBB';
const subject = await translator.translate('[[email:invite, ' + title + ']]', meta.config.defaultLang);
return {
...emailer._defaultPayload, // Append default data to this email payload
site_title: title,
registerLink: registerLink,
subject: subject,
username: username,
template: 'invitation',
expireDays: expireDays,
};
}
};

View File

@@ -3,9 +3,9 @@
<div class="clearfix">
<div class="pull-left">
<!-- IF inviteOnly -->
<!-- IF showInviteButton -->
<button component="user/invite" class="btn btn-success"><i class="fa fa-users"></i> [[admin/manage/users:invite]]</button>
<!-- ENDIF inviteOnly -->
<!-- ENDIF showInviteButton -->
<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>

View File

@@ -0,0 +1,12 @@
<div class="form-group">
<label for="invite-modal-emails">[[users:prompt-email]]</label>
<input id="invite-modal-emails" type="text" class="form-control" placeholder="friend1@example.com,friend2@example.com" />
</div>
<div class="form-group">
<label for="invite-modal-groups">[[users:groups-to-join]]</label>
<select id="invite-modal-groups" class="form-control" multiple size="5">
<!-- BEGIN groups -->
<option value="{@value}">{@value}</option>
<!-- END groups -->
</select>
</div>