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>
v1.18.x
gasoved 4 years ago committed by GitHub
parent dde9f1890f
commit 3ccebf112e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@
"upload-files": "Upload Files",
"signature": "Signature",
"ban": "Ban",
"invite": "Invite",
"search-content": "Search Content",
"search-users": "Search Users",
"search-tags": "Search Tags",

@ -11,6 +11,7 @@
"online-only": "Online only",
"invite": "Invite",
"prompt-email": "Emails:",
"groups-to-join": "Groups to be joined when invite is accepted:",
"invitation-email-sent": "An invitation email has been sent to %1",
"user_list": "User List",
"recent_topics": "Recent Topics",

@ -27,6 +27,8 @@ get:
type: string
sort_lastonline:
type: boolean
showInviteButton:
type: boolean
inviteOnly:
type: boolean
adminInviteOnly:

@ -44,6 +44,10 @@ paths:
$ref: 'write/users/uid/tokens/token.yaml'
/users/{uid}/sessions/{uuid}:
$ref: 'write/users/uid/sessions/uuid.yaml'
/users/{uid}/invites:
$ref: 'write/users/uid/invites.yaml'
/users/{uid}/invites/groups:
$ref: 'write/users/uid/invites/groups.yaml'
/categories/:
$ref: 'write/categories.yaml'
/groups/:

@ -0,0 +1,48 @@
post:
tags:
- users
summary: invite users with email by email
description: This operation sends an invitation email to the given addresses, with an option to join selected groups on acceptance
parameters:
- in: path
name: uid
schema:
type: integer
required: true
description: uid of the user sending invitations
example: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
emails:
type: string
description: A single or list of comma separated email addresses
example: friend01@example.com,friend02@example.com
groupsToJoin:
type: array
description: A collection of group names
example: ['administrators']
required:
- emails
responses:
'200':
description: invitation email(s) sent
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: object
'400':
$ref: ../../../components/responses/400.yaml#/400
'401':
$ref: ../../../components/responses/401.yaml#/401
'403':
$ref: ../../../components/responses/403.yaml#/403

@ -0,0 +1,23 @@
get:
tags:
- users
summary: Get group names that the user can invite
parameters:
- in: path
name: uid
schema:
type: integer
required: true
description: uid of the user to make the query for
example: 1
responses:
'200':
description: A collection of group names returned
content:
application/json:
schema:
type: array
items:
type: string
'401':
$ref: ../../../../components/responses/401.yaml#/401

@ -1,8 +1,8 @@
'use strict';
define('admin/manage/users', [
'translator', 'benchpress', 'autocomplete', 'api', 'slugify',
], function (translator, Benchpress, autocomplete, api, slugify) {
'translator', 'benchpress', 'autocomplete', 'api', 'slugify', 'bootbox',
], function (translator, Benchpress, autocomplete, api, slugify, bootbox) {
var Users = {};
Users.init = function () {
@ -454,20 +454,55 @@ define('admin/manage/users', [
}
function handleInvite() {
$('[component="user/invite"]').on('click', function () {
bootbox.prompt('[[admin/manage/users:alerts.prompt-email]]', function (email) {
if (!email) {
return;
}
socket.emit('user.invite', email, function (err) {
if (err) {
return app.alertError(err.message);
}
app.alertSuccess('[[admin/manage/users:alerts.email-sent-to, ' + email + ']]');
$('[component="user/invite"]').on('click', function (e) {
e.preventDefault();
api.get(`/api/v3/users/${app.user.uid}/invites/groups`, {}).then((groups) => {
Benchpress.parse('modals/invite', { groups: groups }, function (html) {
bootbox.dialog({
message: html,
title: '[[admin/manage/users:invite]]',
onEscape: true,
buttons: {
cancel: {
label: '[[admin/manage/users:alerts.button-cancel]]',
className: 'btn-default',
},
invite: {
label: '[[admin/manage/users:invite]]',
className: 'btn-primary',
callback: sendInvites,
},
},
});
});
}).catch((err) => {
app.alertError(err.message);
});
return false;
});
}
function sendInvites() {
var $emails = $('#invite-modal-emails');
var $groups = $('#invite-modal-groups');
var data = {
emails: $emails.val()
.split(',')
.map(m => m.trim())
.filter(Boolean)
.filter((m, i, arr) => i === arr.indexOf(m))
.join(','),
groupsToJoin: $groups.val(),
};
if (!data.emails) {
return;
}
api.post(`/users/${app.user.uid}/invites`, data).then(() => {
app.alertSuccess('[[admin/manage/users:alerts.email-sent-to, ' + data.emails.replace(/,/g, '&#44; ') + ']]');
}).catch((err) => {
app.alertError(err.message);
});
}

@ -2,8 +2,8 @@
define('forum/users', [
'translator', 'benchpress', 'api',
], function (translator, Benchpress, api) {
'translator', 'benchpress', 'api', 'bootbox',
], function (translator, Benchpress, api, bootbox) {
var Users = {};
var searchTimeoutID = 0;
@ -136,21 +136,57 @@ define('forum/users', [
}
function handleInvite() {
$('[component="user/invite"]').on('click', function () {
bootbox.prompt('[[users:prompt-email]]', function (email) {
if (!email) {
return;
}
socket.emit('user.invite', email, function (err) {
if (err) {
return app.alertError(err.message);
}
app.alertSuccess('[[users:invitation-email-sent, ' + email + ']]');
$('[component="user/invite"]').on('click', function (e) {
e.preventDefault();
api.get(`/api/v3/users/${app.user.uid}/invites/groups`, {}).then((groups) => {
Benchpress.parse('modals/invite', { groups: groups }, function (html) {
bootbox.dialog({
message: html,
title: '[[users:invite]]',
onEscape: true,
buttons: {
cancel: {
label: '[[modules:bootbox.cancel]]',
className: 'btn-default',
},
invite: {
label: '[[users:invite]]',
className: 'btn-primary',
callback: sendInvites,
},
},
});
});
}).catch((err) => {
app.alertError(err.message);
});
});
}
function sendInvites() {
var $emails = $('#invite-modal-emails');
var $groups = $('#invite-modal-groups');
var data = {
emails: $emails.val()
.split(',')
.map(m => m.trim())
.filter(Boolean)
.filter((m, i, arr) => i === arr.indexOf(m))
.join(','),
groupsToJoin: $groups.val(),
};
if (!data.emails) {
return;
}
api.post(`/users/${app.user.uid}/invites`, data).then(() => {
app.alertSuccess('[[users:invitation-email-sent, ' + data.emails.replace(/,/g, '&#44; ') + ']]');
}).catch((err) => {
app.alertError(err.message);
});
}
return Users;
});

@ -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);
}

@ -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);
}

@ -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);

@ -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);
};

@ -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);
};
};

@ -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',

@ -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;
};
};

@ -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 () {

@ -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);
};

@ -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,
};
}
};

@ -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>

@ -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>

@ -763,6 +763,7 @@ describe('Categories', function () {
assert.ifError(err);
assert.deepEqual(data, {
ban: false,
invite: false,
chat: false,
'search:content': false,
'search:users': false,
@ -812,6 +813,7 @@ describe('Categories', function () {
assert.ifError(err);
assert.deepEqual(data, {
'groups:ban': false,
'groups:invite': false,
'groups:chat': true,
'groups:search:content': true,
'groups:search:users': true,

@ -1,6 +1,7 @@
'use strict';
var request = require('request');
const requestAsync = require('request-promise-native');
var nconf = require('nconf');
var fs = require('fs');
var winston = require('winston');
@ -162,4 +163,20 @@ helpers.copyFile = function (source, target, callback) {
}
};
helpers.invite = async function (body, uid, jar, csrf_token) {
const res = await requestAsync.post(`${nconf.get('url')}/api/v3/users/${uid}/invites`, {
jar: jar,
// using "form" since client "api" module make requests with "application/x-www-form-urlencoded" content-type
form: body,
headers: {
'x-csrf-token': csrf_token,
},
simple: false,
resolveWithFullResponse: true,
});
res.body = JSON.parse(res.body);
return { res, body };
};
require('../../src/promisify')(helpers);

@ -5,6 +5,7 @@ var async = require('async');
var path = require('path');
var nconf = require('nconf');
var request = require('request');
const requestAsync = require('request-promise-native');
var jwt = require('jsonwebtoken');
var db = require('./mocks/databasemock');
@ -1919,160 +1920,374 @@ describe('User', function () {
});
describe('invites', function () {
var socketUser = require('../src/socket.io/user');
var notAnInviterUid;
var inviterUid;
var adminUid;
var PUBLIC_GROUP = 'publicGroup';
var PRIVATE_GROUP = 'privateGroup';
var OWN_PRIVATE_GROUP = 'ownPrivateGroup';
var HIDDEN_GROUP = 'hiddenGroup';
var COMMON_PW = '123456';
before(function (done) {
async.parallel({
inviter: async.apply(User.create, { username: 'inviter', email: 'inviter@nodebb.org' }),
admin: async.apply(User.create, { username: 'adminInvite' }),
publicGroup: async.apply(groups.create, { name: PUBLIC_GROUP, private: 0 }),
privateGroup: async.apply(groups.create, { name: PRIVATE_GROUP, private: 1 }),
hiddenGroup: async.apply(groups.create, { name: HIDDEN_GROUP, hidden: 1 }),
notAnInviter: async.apply(User.create, { username: 'notAnInviter', password: COMMON_PW, email: 'notaninviter@nodebb.org' }),
inviter: async.apply(User.create, { username: 'inviter', password: COMMON_PW, email: 'inviter@nodebb.org' }),
admin: async.apply(User.create, { username: 'adminInvite', password: COMMON_PW }),
}, function (err, results) {
assert.ifError(err);
notAnInviterUid = results.notAnInviter;
inviterUid = results.inviter;
adminUid = results.admin;
groups.join('administrators', adminUid, done);
async.parallel([
async.apply(groups.create, { name: OWN_PRIVATE_GROUP, ownerUid: inviterUid, private: 1 }),
async.apply(groups.join, 'administrators', adminUid),
async.apply(groups.join, 'cid:0:privileges:invite', inviterUid),
], done);
});
});
it('should error with invalid data', function (done) {
socketUser.invite({ uid: inviterUid }, null, function (err) {
assert.equal(err.message, '[[error:invalid-data]]');
done();
describe('when inviter is not an admin and does not have invite privilege', function () {
var csrf_token;
var jar;
before(function (done) {
helpers.loginUser('notAnInviter', COMMON_PW, function (err, _jar) {
assert.ifError(err);
jar = _jar;
request({
url: nconf.get('url') + '/api/config',
json: true,
jar: jar,
}, function (err, response, body) {
assert.ifError(err);
csrf_token = body.csrf_token;
done();
});
});
});
});
it('should eror if forum is not invite only', function (done) {
socketUser.invite({ uid: inviterUid }, 'invite1@test.com', function (err) {
assert.equal(err.message, '[[error:forum-not-invite-only]]');
done();
it('should error if user does not have invite privilege', async () => {
const { res } = await helpers.invite({ emails: 'invite1@test.com', groupsToJoin: [] }, notAnInviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 403);
assert.strictEqual(res.body.status.message, '[[error:no-privileges]]');
});
});
it('should error if user is not admin and type is admin-invite-only', function (done) {
meta.config.registrationType = 'admin-invite-only';
socketUser.invite({ uid: inviterUid }, 'invite1@test.com', function (err) {
assert.equal(err.message, '[[error:no-privileges]]');
done();
it('should error out if user tries to use an inviter\'s uid via the API', async () => {
const { res } = await helpers.invite({ emails: 'invite1@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token);
const numInvites = await User.getInvitesNumber(inviterUid);
assert.strictEqual(res.statusCode, 403);
assert.strictEqual(res.body.status.message, '[[error:no-privileges]]');
assert.strictEqual(numInvites, 0);
});
});
it('should send invitation email', function (done) {
meta.config.registrationType = 'invite-only';
socketUser.invite({ uid: inviterUid }, 'invite1@test.com', function (err) {
assert.ifError(err);
done();
describe('when inviter has invite privilege', function () {
var csrf_token;
var jar;
before(function (done) {
helpers.loginUser('inviter', COMMON_PW, function (err, _jar) {
assert.ifError(err);
jar = _jar;
request({
url: nconf.get('url') + '/api/config',
json: true,
jar: jar,
}, function (err, response, body) {
assert.ifError(err);
csrf_token = body.csrf_token;
done();
});
});
});
});
it('should error if ouf of invitations', function (done) {
meta.config.maximumInvites = 1;
socketUser.invite({ uid: inviterUid }, 'invite2@test.com', function (err) {
assert.equal(err.message, '[[error:invite-maximum-met, ' + 1 + ', ' + 1 + ']]');
meta.config.maximumInvites = 5;
done();
it('should error with invalid data', async () => {
const { res } = await helpers.invite({}, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(res.body.status.message, '[[error:invalid-data]]');
});
});
it('should error if email exists', function (done) {
socketUser.invite({ uid: inviterUid }, 'inviter@nodebb.org', function (err) {
assert.equal(err.message, '[[error:email-taken]]');
done();
it('should error if user is not admin and type is admin-invite-only', async () => {
meta.config.registrationType = 'admin-invite-only';
const { res } = await helpers.invite({ emails: 'invite1@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 403);
assert.strictEqual(res.body.status.message, '[[error:no-privileges]]');
});
});
it('should send invitation email', function (done) {
socketUser.invite({ uid: inviterUid }, 'invite2@test.com', function (err) {
assert.ifError(err);
done();
it('should send invitation email (without groups to be joined)', async () => {
meta.config.registrationType = 'normal';
const { res } = await helpers.invite({ emails: 'invite1@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 200);
});
});
it('should get user\'s invites', function (done) {
User.getInvites(inviterUid, function (err, data) {
assert.ifError(err);
assert.notEqual(data.indexOf('invite1@test.com'), -1);
assert.notEqual(data.indexOf('invite2@test.com'), -1);
done();
it('should send multiple invitation emails (with a public group to be joined)', async () => {
const { res } = await helpers.invite({ emails: 'invite2@test.com,invite3@test.com', groupsToJoin: [PUBLIC_GROUP] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 200);
});
});
it('should get all invites', function (done) {
User.getAllInvites(function (err, data) {
assert.ifError(err);
assert.equal(data[0].uid, inviterUid);
assert.notEqual(data[0].invitations.indexOf('invite1@test.com'), -1);
assert.notEqual(data[0].invitations.indexOf('invite2@test.com'), -1);
done();
it('should error if the user has not permission to invite to the group', async () => {
const { res } = await helpers.invite({ emails: 'invite4@test.com', groupsToJoin: [PRIVATE_GROUP] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 403);
assert.strictEqual(res.body.status.message, '[[error:no-privileges]]');
});
});
it('should fail to verify invitation with invalid data', function (done) {
User.verifyInvitation({ token: '', email: '' }, function (err) {
assert.equal(err.message, '[[error:invalid-data]]');
done();
it('should error if a non-admin tries to invite to the administrators group', async () => {
const { res } = await helpers.invite({ emails: 'invite4@test.com', groupsToJoin: ['administrators'] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 403);
assert.strictEqual(res.body.status.message, '[[error:no-privileges]]');
});
it('should to invite to own private group', async () => {
const { res } = await helpers.invite({ emails: 'invite4@test.com', groupsToJoin: [OWN_PRIVATE_GROUP] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 200);
});
it('should to invite to multiple groups', async () => {
const { res } = await helpers.invite({ emails: 'invite5@test.com', groupsToJoin: [PUBLIC_GROUP, OWN_PRIVATE_GROUP] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 200);
});
it('should error if tries to invite to hidden group', async () => {
const { res } = await helpers.invite({ emails: 'invite6@test.com', groupsToJoin: [HIDDEN_GROUP] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 403);
});
it('should error if ouf of invitations', async () => {
meta.config.maximumInvites = 1;
const { res } = await helpers.invite({ emails: 'invite6@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 403);
assert.strictEqual(res.body.status.message, '[[error:invite-maximum-met, ' + 5 + ', ' + 1 + ']]');
meta.config.maximumInvites = 10;
});
it('should send invitation email after maximumInvites increased', async () => {
const { res } = await helpers.invite({ emails: 'invite6@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 200);
});
it('should error if invite is sent via API with a different UID', async () => {
const { res } = await helpers.invite({ emails: 'inviter@nodebb.org', groupsToJoin: [] }, adminUid, jar, csrf_token);
const numInvites = await User.getInvitesNumber(adminUid);
assert.strictEqual(res.statusCode, 403);
assert.strictEqual(res.body.status.message, '[[error:no-privileges]]');
assert.strictEqual(numInvites, 0);
});
it('should error if email exists', async () => {
const { res } = await helpers.invite({ emails: 'inviter@nodebb.org', groupsToJoin: [] }, inviterUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(res.body.status.message, '[[error:email-taken]]');
});
});
it('should fail to verify invitation with invalid email', function (done) {
User.verifyInvitation({ token: 'test', email: 'doesnotexist@test.com' }, function (err) {
assert.equal(err.message, '[[error:invalid-token]]');
done();
describe('when inviter is an admin', function () {
var csrf_token;
var jar;
before(function (done) {
helpers.loginUser('adminInvite', COMMON_PW, function (err, _jar) {
assert.ifError(err);
jar = _jar;
request({
url: nconf.get('url') + '/api/config',
json: true,
jar: jar,
}, function (err, response, body) {
assert.ifError(err);
csrf_token = body.csrf_token;
done();
});
});
});
it('should escape email', async () => {
await helpers.invite({ emails: '<script>alert("ok");</script>', groupsToJoin: [] }, adminUid, jar, csrf_token);
const data = await User.getInvites(adminUid);
assert.strictEqual(data[0], '&lt;script&gt;alert(&quot;ok&quot;);&lt;&#x2F;script&gt;');
await User.deleteInvitationKey('<script>alert("ok");</script>');
});
it('should invite to the administrators group if inviter is an admin', async () => {
const { res } = await helpers.invite({ emails: 'invite99@test.com', groupsToJoin: ['administrators'] }, adminUid, jar, csrf_token);
assert.strictEqual(res.statusCode, 200);
});
});
it('should verify installation with no errors', function (done) {
var email = 'invite1@test.com';
db.get('invitation:email:' + email, function (err, token) {
assert.ifError(err);
User.verifyInvitation({ token: token, email: 'invite1@test.com' }, function (err) {
describe('after invites checks', function () {
it('should get user\'s invites', function (done) {
User.getInvites(inviterUid, function (err, data) {
assert.ifError(err);
Array.from(Array(6)).forEach((_, i) => {
assert.notEqual(data.indexOf('invite' + (i + 1) + '@test.com'), -1);
});
done();
});
});
});
it('should error with invalid username', function (done) {
User.deleteInvitation('doesnotexist', 'test@test.com', function (err) {
assert.equal(err.message, '[[error:invalid-username]]');
done();
it('should get all invites', function (done) {
User.getAllInvites(function (err, data) {
assert.ifError(err);
var adminData = data.filter(d => parseInt(d.uid, 10) === adminUid)[0];
assert.notEqual(adminData.invitations.indexOf('invite99@test.com'), -1);
var inviterData = data.filter(d => parseInt(d.uid, 10) === inviterUid)[0];
Array.from(Array(6)).forEach((_, i) => {
assert.notEqual(inviterData.invitations.indexOf('invite' + (i + 1) + '@test.com'), -1);
});
done();
});
});
});
it('should delete invitation', function (done) {
var socketUser = require('../src/socket.io/user');
socketUser.deleteInvitation({ uid: adminUid }, { invitedBy: 'inviter', email: 'invite1@test.com' }, function (err) {
assert.ifError(err);
db.isSetMember('invitation:uid:' + inviterUid, 'invite1@test.com', function (err, isMember) {
it('should fail to verify invitation with invalid data', function (done) {
User.verifyInvitation({ token: '', email: '' }, function (err) {
assert.equal(err.message, '[[error:invalid-data]]');
done();
});
});
it('should fail to verify invitation with invalid email', function (done) {
User.verifyInvitation({ token: 'test', email: 'doesnotexist@test.com' }, function (err) {
assert.equal(err.message, '[[error:invalid-token]]');
done();
});
});
it('should verify installation with no errors', function (done) {
var email = 'invite1@test.com';
db.getObjectField('invitation:email:' + email, 'token', function (err, token) {
assert.ifError(err);
assert.equal(isMember, false);
User.verifyInvitation({ token: token, email: 'invite1@test.com' }, function (err) {
assert.ifError(err);
done();
});
});
});
it('should error with invalid username', function (done) {
User.deleteInvitation('doesnotexist', 'test@test.com', function (err) {
assert.equal(err.message, '[[error:invalid-username]]');
done();
});
});
});
it('should delete invitation key', function (done) {
User.deleteInvitationKey('invite2@test.com', function (err) {
assert.ifError(err);
db.isSetMember('invitation:uid:' + inviterUid, 'invite2@test.com', function (err, isMember) {
it('should delete invitation', function (done) {
var socketUser = require('../src/socket.io/user');
socketUser.deleteInvitation({ uid: adminUid }, { invitedBy: 'inviter', email: 'invite1@test.com' }, function (err) {
assert.ifError(err);
assert.equal(isMember, false);
db.isSetMember('invitation:uids', inviterUid, function (err, isMember) {
db.isSetMember('invitation:uid:' + inviterUid, 'invite1@test.com', function (err, isMember) {
assert.ifError(err);
assert.equal(isMember, false);
done();
});
});
});
it('should delete invitation key', function (done) {
User.deleteInvitationKey('invite99@test.com', function (err) {
assert.ifError(err);
db.isSetMember('invitation:uid:' + adminUid, 'invite99@test.com', function (err, isMember) {
assert.ifError(err);
assert.equal(isMember, false);
db.isSetMember('invitation:uids', adminUid, function (err, isMember) {
assert.ifError(err);
assert.equal(isMember, false);
done();
});
});
});
});
it('should joined the groups from invitation after registration', async function () {
var email = 'invite5@test.com';
var groupsToJoin = [PUBLIC_GROUP, OWN_PRIVATE_GROUP];
var token = await db.getObjectField('invitation:email:' + email, 'token');
await new Promise(function (resolve, reject) {
helpers.registerUser({
username: 'invite5',
password: '123456',
'password-confirm': '123456',
email: email,
gdpr_consent: true,
token: token,
}, async function (err, jar, response, body) {
if (err) {
reject(err);
}
var memberships = await groups.isMemberOfGroups(body.uid, groupsToJoin);
var joinedToAll = memberships.filter(Boolean);
if (joinedToAll.length !== groupsToJoin.length) {
reject(new Error('Not joined to the groups'));
}
resolve();
});
});
});
});
it('should escape email', function (done) {
socketUser.invite({ uid: inviterUid }, '<script>alert("ok");</script>', function (err) {
assert.ifError(err);
User.getInvites(inviterUid, function (err, data) {
describe('invite groups', () => {
var csrf_token;
var jar;
before(function (done) {
helpers.loginUser('inviter', COMMON_PW, function (err, _jar) {
assert.ifError(err);
assert.equal(data[0], '&lt;script&gt;alert(&quot;ok&quot;);&lt;&#x2F;script&gt;');
done();
jar = _jar;
request({
url: nconf.get('url') + '/api/config',
json: true,
jar: jar,
}, function (err, response, body) {
assert.ifError(err);
csrf_token = body.csrf_token;
done();
});
});
});
it('should show a list of groups for adding to an invite', async () => {
const body = await requestAsync({
url: `${nconf.get('url')}/api/v3/users/${inviterUid}/invites/groups`,
json: true,
jar,
});
assert(Array.isArray(body.response));
assert.strictEqual(2, body.response.length);
assert.deepStrictEqual(body.response, ['ownPrivateGroup', 'publicGroup']);
});
it('should error out if you request invite groups for another uid', async () => {
const res = await requestAsync({
url: `${nconf.get('url')}/api/v3/users/${adminUid}/invites/groups`,
json: true,
jar,
simple: false,
resolveWithFullResponse: true,
});
assert.strictEqual(res.statusCode, 401);
assert.deepStrictEqual(res.body, {
status: {
code: 'not-authorised',
message: 'A valid login session was not found. Please log in and try again.',
},
response: {},
});
});
});

Loading…
Cancel
Save