From 3ccebf112e1dd02eaa54904198cd8a08c34850b3 Mon Sep 17 00:00:00 2001 From: gasoved Date: Mon, 16 Nov 2020 22:47:23 +0300 Subject: [PATCH] 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 --- .../en-GB/admin/manage/privileges.json | 1 + public/language/en-GB/users.json | 1 + public/openapi/read/admin/manage/users.yaml | 2 + public/openapi/write.yaml | 4 + public/openapi/write/users/uid/invites.yaml | 48 ++ .../write/users/uid/invites/groups.yaml | 23 + public/src/admin/manage/users.js | 63 ++- public/src/client/users.js | 62 ++- src/controllers/admin/users.js | 13 +- src/controllers/authentication.js | 8 +- src/controllers/users.js | 15 +- src/controllers/write/users.js | 60 ++- src/groups/user.js | 30 ++ src/privileges/global.js | 2 + src/privileges/users.js | 9 + src/routes/write/users.js | 3 + src/socket.io/user.js | 33 -- src/user/invite.js | 76 ++- src/views/admin/manage/users.tpl | 4 +- src/views/modals/invite.tpl | 12 + test/categories.js | 2 + test/helpers/index.js | 17 + test/user.js | 457 +++++++++++++----- 23 files changed, 725 insertions(+), 220 deletions(-) create mode 100644 public/openapi/write/users/uid/invites.yaml create mode 100644 public/openapi/write/users/uid/invites/groups.yaml create mode 100644 src/views/modals/invite.tpl diff --git a/public/language/en-GB/admin/manage/privileges.json b/public/language/en-GB/admin/manage/privileges.json index ac83a28a93..3b24b1cc3e 100644 --- a/public/language/en-GB/admin/manage/privileges.json +++ b/public/language/en-GB/admin/manage/privileges.json @@ -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", diff --git a/public/language/en-GB/users.json b/public/language/en-GB/users.json index 18cf03fde3..87c7d1b4fc 100644 --- a/public/language/en-GB/users.json +++ b/public/language/en-GB/users.json @@ -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", diff --git a/public/openapi/read/admin/manage/users.yaml b/public/openapi/read/admin/manage/users.yaml index 4b47e5f112..c3278c007b 100644 --- a/public/openapi/read/admin/manage/users.yaml +++ b/public/openapi/read/admin/manage/users.yaml @@ -27,6 +27,8 @@ get: type: string sort_lastonline: type: boolean + showInviteButton: + type: boolean inviteOnly: type: boolean adminInviteOnly: diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index c3396ea4ad..b4afa1fcbc 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -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/: diff --git a/public/openapi/write/users/uid/invites.yaml b/public/openapi/write/users/uid/invites.yaml new file mode 100644 index 0000000000..9fd3596296 --- /dev/null +++ b/public/openapi/write/users/uid/invites.yaml @@ -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 \ No newline at end of file diff --git a/public/openapi/write/users/uid/invites/groups.yaml b/public/openapi/write/users/uid/invites/groups.yaml new file mode 100644 index 0000000000..5683db658d --- /dev/null +++ b/public/openapi/write/users/uid/invites/groups.yaml @@ -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 \ No newline at end of file diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js index fbdd42f846..38ab5f88e5 100644 --- a/public/src/admin/manage/users.js +++ b/public/src/admin/manage/users.js @@ -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, ', ') + ']]'); + }).catch((err) => { + app.alertError(err.message); }); } diff --git a/public/src/client/users.js b/public/src/client/users.js index 859d0e686b..70f8783b9c 100644 --- a/public/src/client/users.js +++ b/public/src/client/users.js @@ -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, ', ') + ']]'); + }).catch((err) => { + app.alertError(err.message); + }); + } + return Users; }); diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index 3acd0cd808..2397de158d 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -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); } diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index 2bf8f59d2f..7d52e76082 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -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); } diff --git a/src/controllers/users.js b/src/controllers/users.js index eef5b9c0e5..17d10fd517 100644 --- a/src/controllers/users.js +++ b/src/controllers/users.js @@ -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); diff --git a/src/controllers/write/users.js b/src/controllers/write/users.js index 72f10dbfb0..fe9004b757 100644 --- a/src/controllers/write/users.js +++ b/src/controllers/write/users.js @@ -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); +}; diff --git a/src/groups/user.js b/src/groups/user.js index 4cb01ce9e2..5255adff1a 100644 --- a/src/groups/user.js +++ b/src/groups/user.js @@ -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); + }; }; diff --git a/src/privileges/global.js b/src/privileges/global.js index 8a11e8a71a..3585a432cc 100644 --- a/src/privileges/global.js +++ b/src/privileges/global.js @@ -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', diff --git a/src/privileges/users.js b/src/privileges/users.js index 2f43ceca13..c2bd64777a 100644 --- a/src/privileges/users.js +++ b/src/privileges/users.js @@ -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; + }; }; diff --git a/src/routes/write/users.js b/src/routes/write/users.js index 4edca5d4a6..02b1629a89 100644 --- a/src/routes/write/users.js +++ b/src/routes/write/users.js @@ -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 () { diff --git a/src/socket.io/user.js b/src/socket.io/user.js index cc39126d8a..d4e65748af 100644 --- a/src/socket.io/user.js +++ b/src/socket.io/user.js @@ -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); }; diff --git a/src/user/invite.js b/src/user/invite.js index 82471d8b28..4c20eab70a 100644 --- a/src/user/invite.js +++ b/src/user/invite.js @@ -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, + }; + } }; diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index e950ba7b40..826ed34411 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -3,9 +3,9 @@
- + - + [[admin/manage/users:download-csv]]
diff --git a/src/views/modals/invite.tpl b/src/views/modals/invite.tpl new file mode 100644 index 0000000000..8184bf8c23 --- /dev/null +++ b/src/views/modals/invite.tpl @@ -0,0 +1,12 @@ +
+ + +
+
+ + +
\ No newline at end of file diff --git a/test/categories.js b/test/categories.js index 242abd9294..f717052b5b 100644 --- a/test/categories.js +++ b/test/categories.js @@ -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, diff --git a/test/helpers/index.js b/test/helpers/index.js index f38ef8795c..38239aaee0 100644 --- a/test/helpers/index.js +++ b/test/helpers/index.js @@ -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); diff --git a/test/user.js b/test/user.js index 101373316c..02e3d010f8 100644 --- a/test/user.js +++ b/test/user.js @@ -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; - 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 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 send invitation email', function (done) { - meta.config.registrationType = 'invite-only'; - socketUser.invite({ uid: inviterUid }, 'invite1@test.com', function (err) { - assert.ifError(err); - 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 if email exists', function (done) { - socketUser.invite({ uid: inviterUid }, 'inviter@nodebb.org', function (err) { - assert.equal(err.message, '[[error:email-taken]]'); - done(); - }); - }); - - it('should send invitation email', function (done) { - socketUser.invite({ uid: inviterUid }, 'invite2@test.com', function (err) { - assert.ifError(err); - done(); - }); - }); - - 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 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 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.get('invitation:email:' + email, function (err, token) { - assert.ifError(err); - User.verifyInvitation({ token: token, email: 'invite1@test.com' }, function (err) { + 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 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 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); + }); + }); + + 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 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 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 (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 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 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 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]]'); + }); + }); + + 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: '', groupsToJoin: [] }, adminUid, jar, csrf_token); + const data = await User.getInvites(adminUid); + assert.strictEqual(data[0], '<script>alert("ok");</script>'); + await User.deleteInvitationKey(''); + }); + + 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); + }); + }); + + 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 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 get all invites', function (done) { + User.getAllInvites(function (err, data) { assert.ifError(err); - assert.equal(isMember, false); + + 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 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 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); - db.isSetMember('invitation:uids', inviterUid, function (err, isMember) { + 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', 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) { 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 }, '', 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], '<script>alert("ok");</script>'); - 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: {}, }); }); });