mirror of
				https://github.com/NodeBB/NodeBB.git
				synced 2025-10-27 17:16:14 +01:00 
			
		
		
		
	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:
		| @@ -9,6 +9,7 @@ | |||||||
| 	"upload-files": "Upload Files", | 	"upload-files": "Upload Files", | ||||||
| 	"signature": "Signature", | 	"signature": "Signature", | ||||||
| 	"ban": "Ban", | 	"ban": "Ban", | ||||||
|  | 	"invite": "Invite", | ||||||
| 	"search-content": "Search Content", | 	"search-content": "Search Content", | ||||||
| 	"search-users": "Search Users", | 	"search-users": "Search Users", | ||||||
| 	"search-tags": "Search Tags", | 	"search-tags": "Search Tags", | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ | |||||||
| 	"online-only": "Online only", | 	"online-only": "Online only", | ||||||
| 	"invite": "Invite", | 	"invite": "Invite", | ||||||
| 	"prompt-email": "Emails:", | 	"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", | 	"invitation-email-sent": "An invitation email has been sent to %1", | ||||||
| 	"user_list": "User List", | 	"user_list": "User List", | ||||||
| 	"recent_topics": "Recent Topics", | 	"recent_topics": "Recent Topics", | ||||||
|   | |||||||
| @@ -27,6 +27,8 @@ get: | |||||||
|                     type: string |                     type: string | ||||||
|                   sort_lastonline: |                   sort_lastonline: | ||||||
|                     type: boolean |                     type: boolean | ||||||
|  |                   showInviteButton: | ||||||
|  |                     type: boolean | ||||||
|                   inviteOnly: |                   inviteOnly: | ||||||
|                     type: boolean |                     type: boolean | ||||||
|                   adminInviteOnly: |                   adminInviteOnly: | ||||||
|   | |||||||
| @@ -44,6 +44,10 @@ paths: | |||||||
|     $ref: 'write/users/uid/tokens/token.yaml' |     $ref: 'write/users/uid/tokens/token.yaml' | ||||||
|   /users/{uid}/sessions/{uuid}: |   /users/{uid}/sessions/{uuid}: | ||||||
|     $ref: 'write/users/uid/sessions/uuid.yaml' |     $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/: |   /categories/: | ||||||
|     $ref: 'write/categories.yaml' |     $ref: 'write/categories.yaml' | ||||||
|   /groups/: |   /groups/: | ||||||
|   | |||||||
							
								
								
									
										48
									
								
								public/openapi/write/users/uid/invites.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								public/openapi/write/users/uid/invites.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										23
									
								
								public/openapi/write/users/uid/invites/groups.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								public/openapi/write/users/uid/invites/groups.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -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'; | 'use strict'; | ||||||
|  |  | ||||||
| define('admin/manage/users', [ | define('admin/manage/users', [ | ||||||
| 	'translator', 'benchpress', 'autocomplete', 'api', 'slugify', | 	'translator', 'benchpress', 'autocomplete', 'api', 'slugify', 'bootbox', | ||||||
| ], function (translator, Benchpress, autocomplete, api, slugify) { | ], function (translator, Benchpress, autocomplete, api, slugify, bootbox) { | ||||||
| 	var Users = {}; | 	var Users = {}; | ||||||
|  |  | ||||||
| 	Users.init = function () { | 	Users.init = function () { | ||||||
| @@ -454,20 +454,55 @@ define('admin/manage/users', [ | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	function handleInvite() { | 	function handleInvite() { | ||||||
| 		$('[component="user/invite"]').on('click', function () { | 		$('[component="user/invite"]').on('click', function (e) { | ||||||
| 			bootbox.prompt('[[admin/manage/users:alerts.prompt-email]]', function (email) { | 			e.preventDefault(); | ||||||
| 				if (!email) { | 			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); | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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; | 			return; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 				socket.emit('user.invite', email, function (err) { | 		api.post(`/users/${app.user.uid}/invites`, data).then(() => { | ||||||
| 					if (err) { | 			app.alertSuccess('[[admin/manage/users:alerts.email-sent-to, ' + data.emails.replace(/,/g, ', ') + ']]'); | ||||||
| 						return app.alertError(err.message); | 		}).catch((err) => { | ||||||
| 					} | 			app.alertError(err.message); | ||||||
| 					app.alertSuccess('[[admin/manage/users:alerts.email-sent-to, ' + email + ']]'); |  | ||||||
| 				}); |  | ||||||
| 			}); |  | ||||||
| 			return false; |  | ||||||
| 		}); | 		}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,8 +2,8 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
| define('forum/users', [ | define('forum/users', [ | ||||||
| 	'translator', 'benchpress', 'api', | 	'translator', 'benchpress', 'api', 'bootbox', | ||||||
| ], function (translator, Benchpress, api) { | ], function (translator, Benchpress, api, bootbox) { | ||||||
| 	var	Users = {}; | 	var	Users = {}; | ||||||
|  |  | ||||||
| 	var searchTimeoutID = 0; | 	var searchTimeoutID = 0; | ||||||
| @@ -136,19 +136,55 @@ define('forum/users', [ | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	function handleInvite() { | 	function handleInvite() { | ||||||
| 		$('[component="user/invite"]').on('click', function () { | 		$('[component="user/invite"]').on('click', function (e) { | ||||||
| 			bootbox.prompt('[[users:prompt-email]]', function (email) { | 			e.preventDefault(); | ||||||
| 				if (!email) { | 			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; | 			return; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 				socket.emit('user.invite', email, function (err) { | 		api.post(`/users/${app.user.uid}/invites`, data).then(() => { | ||||||
| 					if (err) { | 			app.alertSuccess('[[users:invitation-email-sent, ' + data.emails.replace(/,/g, ', ') + ']]'); | ||||||
| 						return app.alertError(err.message); | 		}).catch((err) => { | ||||||
| 					} | 			app.alertError(err.message); | ||||||
| 					app.alertSuccess('[[users:invitation-email-sent, ' + email + ']]'); |  | ||||||
| 				}); |  | ||||||
| 			}); |  | ||||||
| 		}); | 		}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ const db = require('../../database'); | |||||||
| const pagination = require('../../pagination'); | const pagination = require('../../pagination'); | ||||||
| const events = require('../../events'); | const events = require('../../events'); | ||||||
| const plugins = require('../../plugins'); | const plugins = require('../../plugins'); | ||||||
|  | const privileges = require('../../privileges'); | ||||||
| const utils = require('../../utils'); | const utils = require('../../utils'); | ||||||
|  |  | ||||||
| const usersController = module.exports; | const usersController = module.exports; | ||||||
| @@ -115,7 +116,7 @@ async function getUsers(req, res) { | |||||||
| 		getUsersWithFields(set), | 		getUsersWithFields(set), | ||||||
| 	]); | 	]); | ||||||
|  |  | ||||||
| 	render(req, res, { | 	await render(req, res, { | ||||||
| 		users: users.filter(user => user && parseInt(user.uid, 10)), | 		users: users.filter(user => user && parseInt(user.uid, 10)), | ||||||
| 		page: page, | 		page: page, | ||||||
| 		pageCount: Math.max(1, Math.ceil(count / resultsPerPage)), | 		pageCount: Math.max(1, Math.ceil(count / resultsPerPage)), | ||||||
| @@ -176,7 +177,7 @@ usersController.search = async function (req, res) { | |||||||
| 	searchData.resultsPerPage = resultsPerPage; | 	searchData.resultsPerPage = resultsPerPage; | ||||||
| 	searchData.sortBy = req.query.sortBy; | 	searchData.sortBy = req.query.sortBy; | ||||||
| 	searchData.reverse = reverse; | 	searchData.reverse = reverse; | ||||||
| 	render(req, res, searchData); | 	await render(req, res, searchData); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| usersController.registrationQueue = async function (req, res) { | usersController.registrationQueue = async function (req, res) { | ||||||
| @@ -226,7 +227,7 @@ async function getInvites() { | |||||||
| 	return invitations; | 	return invitations; | ||||||
| } | } | ||||||
|  |  | ||||||
| function render(req, res, data) { | async function render(req, res, data) { | ||||||
| 	data.pagination = pagination.create(data.page, data.pageCount, req.query); | 	data.pagination = pagination.create(data.page, data.pageCount, req.query); | ||||||
|  |  | ||||||
| 	const registrationType = meta.config.registrationType; | 	const registrationType = meta.config.registrationType; | ||||||
| @@ -241,6 +242,12 @@ function render(req, res, data) { | |||||||
| 	filterBy.forEach(function (filter) { | 	filterBy.forEach(function (filter) { | ||||||
| 		data['filterBy_' + validator.escape(String(filter))] = true; | 		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); | 	res.render('admin/manage/users', data); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -55,7 +55,11 @@ async function registerAndLoginUser(req, res, userData) { | |||||||
| 		await authenticationController.doLogin(req, uid); | 		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 referrer = req.body.referrer || req.session.referrer || nconf.get('relative_path') + '/'; | ||||||
| 	const complete = await plugins.fireHook('filter:register.complete', { uid: uid, referrer: referrer }); | 	const complete = await plugins.fireHook('filter:register.complete', { uid: uid, referrer: referrer }); | ||||||
| 	req.session.returnTo = complete.referrer; | 	req.session.returnTo = complete.referrer; | ||||||
| @@ -74,7 +78,7 @@ authenticationController.register = async function (req, res) { | |||||||
|  |  | ||||||
| 	const userData = req.body; | 	const userData = req.body; | ||||||
| 	try { | 	try { | ||||||
| 		if (registrationType === 'invite-only' || registrationType === 'admin-invite-only') { | 		if (userData.token || registrationType === 'invite-only' || registrationType === 'admin-invite-only') { | ||||||
| 			await user.verifyInvitation(userData); | 			await user.verifyInvitation(userData); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -96,7 +96,7 @@ async function renderIfAdminOrGlobalMod(set, req, res) { | |||||||
|  |  | ||||||
| usersController.renderUsersPage = async function (set, req, res) { | usersController.renderUsersPage = async function (set, req, res) { | ||||||
| 	const userData = await usersController.getUsers(set, req.uid, req.query); | 	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) { | 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.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only'; | ||||||
| 	data.adminInviteOnly = registrationType === 'admin-invite-only'; | 	data.adminInviteOnly = registrationType === 'admin-invite-only'; | ||||||
| 	data.invites = await user.getInvitesNumber(req.uid); | 	data.invites = await user.getInvitesNumber(req.uid); | ||||||
| 	data.showInviteButton = req.loggedIn && ( |  | ||||||
| 		(registrationType === 'invite-only' && (data.isAdmin || !data.maximumInvites || data.invites < data.maximumInvites)) || | 	data.showInviteButton = false; | ||||||
| 		(registrationType === 'admin-invite-only' && data.isAdmin) | 	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']; | 	data['reputation:disabled'] = meta.config['reputation:disabled']; | ||||||
|  |  | ||||||
| 	res.append('X-Total-Count', data.userCount); | 	res.append('X-Total-Count', data.userCount); | ||||||
|   | |||||||
| @@ -5,9 +5,10 @@ const nconf = require('nconf'); | |||||||
|  |  | ||||||
| const db = require('../../database'); | const db = require('../../database'); | ||||||
| const api = require('../../api'); | const api = require('../../api'); | ||||||
| const user = require('../../user'); | const groups = require('../../groups'); | ||||||
| const meta = require('../../meta'); | const meta = require('../../meta'); | ||||||
| const privileges = require('../../privileges'); | const privileges = require('../../privileges'); | ||||||
|  | const user = require('../../user'); | ||||||
| const utils = require('../../utils'); | const utils = require('../../utils'); | ||||||
|  |  | ||||||
| const helpers = require('../helpers'); | const helpers = require('../helpers'); | ||||||
| @@ -153,3 +154,60 @@ Users.revokeSession = async (req, res) => { | |||||||
| 	await user.auth.revokeSession(_id, req.params.uid); | 	await user.auth.revokeSession(_id, req.params.uid); | ||||||
| 	helpers.formatApiResponse(200, res); | 	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); | 		const isMembers = await Groups.isMemberOfGroups(uid, groupNames); | ||||||
| 		return groupNames.filter((name, i) => isMembers[i]); | 		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:upload-files]]' }, | ||||||
| 		{ name: '[[admin/manage/privileges:signature]]' }, | 		{ name: '[[admin/manage/privileges:signature]]' }, | ||||||
| 		{ name: '[[admin/manage/privileges:ban]]' }, | 		{ name: '[[admin/manage/privileges:ban]]' }, | ||||||
|  | 		{ name: '[[admin/manage/privileges:invite]]' }, | ||||||
| 		{ name: '[[admin/manage/privileges:search-content]]' }, | 		{ name: '[[admin/manage/privileges:search-content]]' }, | ||||||
| 		{ name: '[[admin/manage/privileges:search-users]]' }, | 		{ name: '[[admin/manage/privileges:search-users]]' }, | ||||||
| 		{ name: '[[admin/manage/privileges:search-tags]]' }, | 		{ name: '[[admin/manage/privileges:search-tags]]' }, | ||||||
| @@ -35,6 +36,7 @@ module.exports = function (privileges) { | |||||||
| 		'upload:post:file', | 		'upload:post:file', | ||||||
| 		'signature', | 		'signature', | ||||||
| 		'ban', | 		'ban', | ||||||
|  | 		'invite', | ||||||
| 		'search:content', | 		'search:content', | ||||||
| 		'search:users', | 		'search:users', | ||||||
| 		'search:tags', | 		'search:tags', | ||||||
|   | |||||||
| @@ -115,4 +115,13 @@ module.exports = function (privileges) { | |||||||
| 		}); | 		}); | ||||||
| 		return data.canBan; | 		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 | 	// Shorthand route to access user routes by userslug | ||||||
| 	router.all('/+bySlug/:userslug*?', [], controllers.write.users.redirectBySlug); | 	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 () { | module.exports = function () { | ||||||
|   | |||||||
| @@ -1,7 +1,5 @@ | |||||||
| 'use strict'; | 'use strict'; | ||||||
|  |  | ||||||
| const async = require('async'); |  | ||||||
|  |  | ||||||
| const util = require('util'); | const util = require('util'); | ||||||
| const sleep = util.promisify(setTimeout); | const sleep = util.promisify(setTimeout); | ||||||
|  |  | ||||||
| @@ -223,37 +221,6 @@ SocketUser.getUnreadCounts = async function (socket) { | |||||||
| 	return results; | 	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) { | SocketUser.getUserByUID = async function (socket, uid) { | ||||||
| 	return await userController.getUserDataByField(socket.uid, 'uid', uid); | 	return await userController.getUserDataByField(socket.uid, 'uid', uid); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ var validator = require('validator'); | |||||||
| var db = require('../database'); | var db = require('../database'); | ||||||
| var meta = require('../meta'); | var meta = require('../meta'); | ||||||
| var emailer = require('../emailer'); | var emailer = require('../emailer'); | ||||||
|  | var groups = require('../groups'); | ||||||
| var translator = require('../translator'); | var translator = require('../translator'); | ||||||
| var utils = require('../utils'); | var utils = require('../utils'); | ||||||
|  |  | ||||||
| @@ -36,13 +37,7 @@ module.exports = function (User) { | |||||||
| 		}); | 		}); | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	User.sendInvitationEmail = async function (uid, email) { | 	User.sendInvitationEmail = async function (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; |  | ||||||
|  |  | ||||||
| 		const email_exists = await User.getUidByEmail(email); | 		const email_exists = await User.getUidByEmail(email); | ||||||
| 		if (email_exists) { | 		if (email_exists) { | ||||||
| 			throw new Error('[[error:email-taken]]'); | 			throw new Error('[[error:email-taken]]'); | ||||||
| @@ -53,24 +48,7 @@ module.exports = function (User) { | |||||||
| 			throw new Error('[[error:email-invited]]'); | 			throw new Error('[[error:email-invited]]'); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		await db.setAdd('invitation:uid:' + uid, email); | 		const data = await prepareInvitation(uid, email, groupsToJoin); | ||||||
| 		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 }; |  | ||||||
|  |  | ||||||
| 		await emailer.sendToEmail('invitation', email, meta.config.defaultLang, data); | 		await emailer.sendToEmail('invitation', email, meta.config.defaultLang, data); | ||||||
| 	}; | 	}; | ||||||
| @@ -79,12 +57,28 @@ module.exports = function (User) { | |||||||
| 		if (!query.token || !query.email) { | 		if (!query.token || !query.email) { | ||||||
| 			throw new Error('[[error:invalid-data]]'); | 			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) { | 		if (!token || token !== query.token) { | ||||||
| 			throw new Error('[[error:invalid-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) { | 	User.deleteInvitation = async function (invitedBy, email) { | ||||||
| 		const invitedByUid = await User.getUidByUsername(invitedBy); | 		const invitedByUid = await User.getUidByUsername(invitedBy); | ||||||
| 		if (!invitedByUid) { | 		if (!invitedByUid) { | ||||||
| @@ -109,4 +103,34 @@ module.exports = function (User) { | |||||||
| 			await db.setRemove('invitation:uids', uid); | 			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="clearfix"> | ||||||
|  |  | ||||||
| 			<div class="pull-left"> | 			<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> | 				<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> | 				<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"> | 				<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> | 					<button class="btn btn-default dropdown-toggle" data-toggle="dropdown" type="button">[[admin/manage/users:edit]] <span class="caret"></span></button> | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								src/views/modals/invite.tpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/views/modals/invite.tpl
									
									
									
									
									
										Normal 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> | ||||||
| @@ -763,6 +763,7 @@ describe('Categories', function () { | |||||||
| 				assert.ifError(err); | 				assert.ifError(err); | ||||||
| 				assert.deepEqual(data, { | 				assert.deepEqual(data, { | ||||||
| 					ban: false, | 					ban: false, | ||||||
|  | 					invite: false, | ||||||
| 					chat: false, | 					chat: false, | ||||||
| 					'search:content': false, | 					'search:content': false, | ||||||
| 					'search:users': false, | 					'search:users': false, | ||||||
| @@ -812,6 +813,7 @@ describe('Categories', function () { | |||||||
| 				assert.ifError(err); | 				assert.ifError(err); | ||||||
| 				assert.deepEqual(data, { | 				assert.deepEqual(data, { | ||||||
| 					'groups:ban': false, | 					'groups:ban': false, | ||||||
|  | 					'groups:invite': false, | ||||||
| 					'groups:chat': true, | 					'groups:chat': true, | ||||||
| 					'groups:search:content': true, | 					'groups:search:content': true, | ||||||
| 					'groups:search:users': true, | 					'groups:search:users': true, | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| 'use strict'; | 'use strict'; | ||||||
|  |  | ||||||
| var request = require('request'); | var request = require('request'); | ||||||
|  | const requestAsync = require('request-promise-native'); | ||||||
| var nconf = require('nconf'); | var nconf = require('nconf'); | ||||||
| var fs = require('fs'); | var fs = require('fs'); | ||||||
| var winston = require('winston'); | 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); | require('../../src/promisify')(helpers); | ||||||
|   | |||||||
							
								
								
									
										309
									
								
								test/user.js
									
									
									
									
									
								
							
							
						
						
									
										309
									
								
								test/user.js
									
									
									
									
									
								
							| @@ -5,6 +5,7 @@ var async = require('async'); | |||||||
| var path = require('path'); | var path = require('path'); | ||||||
| var nconf = require('nconf'); | var nconf = require('nconf'); | ||||||
| var request = require('request'); | var request = require('request'); | ||||||
|  | const requestAsync = require('request-promise-native'); | ||||||
| var jwt = require('jsonwebtoken'); | var jwt = require('jsonwebtoken'); | ||||||
|  |  | ||||||
| var db = require('./mocks/databasemock'); | var db = require('./mocks/databasemock'); | ||||||
| @@ -1919,80 +1920,215 @@ describe('User', function () { | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	describe('invites', function () { | 	describe('invites', function () { | ||||||
| 		var socketUser = require('../src/socket.io/user'); | 		var notAnInviterUid; | ||||||
| 		var inviterUid; | 		var inviterUid; | ||||||
| 		var adminUid; | 		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) { | 		before(function (done) { | ||||||
| 			async.parallel({ | 			async.parallel({ | ||||||
| 				inviter: async.apply(User.create, { username: 'inviter', email: 'inviter@nodebb.org' }), | 				publicGroup: async.apply(groups.create, { name: PUBLIC_GROUP, private: 0 }), | ||||||
| 				admin: async.apply(User.create, { username: 'adminInvite' }), | 				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) { | 			}, function (err, results) { | ||||||
| 				assert.ifError(err); | 				assert.ifError(err); | ||||||
|  | 				notAnInviterUid = results.notAnInviter; | ||||||
| 				inviterUid = results.inviter; | 				inviterUid = results.inviter; | ||||||
| 				adminUid = results.admin; | 				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) { | 		describe('when inviter is not an admin and does not have invite privilege', function () { | ||||||
| 			socketUser.invite({ uid: inviterUid }, null, function (err) { | 			var csrf_token; | ||||||
| 				assert.equal(err.message, '[[error:invalid-data]]'); | 			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(); | 						done(); | ||||||
| 					}); | 					}); | ||||||
| 				}); | 				}); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
| 		it('should eror if forum is not invite only', function (done) { | 			it('should error if user does not have invite privilege', async () => { | ||||||
| 			socketUser.invite({ uid: inviterUid }, 'invite1@test.com', function (err) { | 				const { res } = await helpers.invite({ emails: 'invite1@test.com', groupsToJoin: [] }, notAnInviterUid, jar, csrf_token); | ||||||
| 				assert.equal(err.message, '[[error:forum-not-invite-only]]'); | 				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(); | 						done(); | ||||||
| 					}); | 					}); | ||||||
| 				}); | 				}); | ||||||
|  | 			}); | ||||||
|  |  | ||||||
| 		it('should error if user is not admin and type is admin-invite-only', function (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'; | 				meta.config.registrationType = 'admin-invite-only'; | ||||||
| 			socketUser.invite({ uid: inviterUid }, 'invite1@test.com', function (err) { | 				const { res } = await helpers.invite({ emails: 'invite1@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token); | ||||||
| 				assert.equal(err.message, '[[error:no-privileges]]'); | 				assert.strictEqual(res.statusCode, 403); | ||||||
| 				done(); | 				assert.strictEqual(res.body.status.message, '[[error:no-privileges]]'); | ||||||
| 			}); |  | ||||||
| 			}); | 			}); | ||||||
|  |  | ||||||
| 		it('should send invitation email', function (done) { | 			it('should send invitation email (without groups to be joined)', async () => { | ||||||
| 			meta.config.registrationType = 'invite-only'; | 				meta.config.registrationType = 'normal'; | ||||||
| 			socketUser.invite({ uid: inviterUid }, 'invite1@test.com', function (err) { | 				const { res } = await helpers.invite({ emails: 'invite1@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token); | ||||||
| 				assert.ifError(err); | 				assert.strictEqual(res.statusCode, 200); | ||||||
| 				done(); |  | ||||||
| 			}); |  | ||||||
| 			}); | 			}); | ||||||
|  |  | ||||||
| 		it('should error if ouf of invitations', function (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 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; | 				meta.config.maximumInvites = 1; | ||||||
| 			socketUser.invite({ uid: inviterUid }, 'invite2@test.com', function (err) { | 				const { res } = await helpers.invite({ emails: 'invite6@test.com', groupsToJoin: [] }, inviterUid, jar, csrf_token); | ||||||
| 				assert.equal(err.message, '[[error:invite-maximum-met, ' + 1 + ', ' + 1 + ']]'); | 				assert.strictEqual(res.statusCode, 403); | ||||||
| 				meta.config.maximumInvites = 5; | 				assert.strictEqual(res.body.status.message, '[[error:invite-maximum-met, ' + 5 + ', ' + 1 + ']]'); | ||||||
| 				done(); | 				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 error if email exists', function (done) { | 		describe('when inviter is an admin', function () { | ||||||
| 			socketUser.invite({ uid: inviterUid }, 'inviter@nodebb.org', function (err) { | 			var csrf_token; | ||||||
| 				assert.equal(err.message, '[[error:email-taken]]'); | 			var jar; | ||||||
| 				done(); |  | ||||||
| 			}); |  | ||||||
| 		}); |  | ||||||
|  |  | ||||||
| 		it('should send invitation email', function (done) { | 			before(function (done) { | ||||||
| 			socketUser.invite({ uid: inviterUid }, 'invite2@test.com', function (err) { | 				helpers.loginUser('adminInvite', COMMON_PW, function (err, _jar) { | ||||||
| 					assert.ifError(err); | 					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(); | 						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], '<script>alert("ok");</script>'); | ||||||
|  | 				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); | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		describe('after invites checks', function () { | ||||||
| 			it('should get user\'s invites', function (done) { | 			it('should get user\'s invites', function (done) { | ||||||
| 				User.getInvites(inviterUid, function (err, data) { | 				User.getInvites(inviterUid, function (err, data) { | ||||||
| 					assert.ifError(err); | 					assert.ifError(err); | ||||||
| 				assert.notEqual(data.indexOf('invite1@test.com'), -1); | 					Array.from(Array(6)).forEach((_, i) => { | ||||||
| 				assert.notEqual(data.indexOf('invite2@test.com'), -1); | 						assert.notEqual(data.indexOf('invite' + (i + 1) + '@test.com'), -1); | ||||||
|  | 					}); | ||||||
| 					done(); | 					done(); | ||||||
| 				}); | 				}); | ||||||
| 			}); | 			}); | ||||||
| @@ -2000,9 +2136,15 @@ describe('User', function () { | |||||||
| 			it('should get all invites', function (done) { | 			it('should get all invites', function (done) { | ||||||
| 				User.getAllInvites(function (err, data) { | 				User.getAllInvites(function (err, data) { | ||||||
| 					assert.ifError(err); | 					assert.ifError(err); | ||||||
| 				assert.equal(data[0].uid, inviterUid); |  | ||||||
| 				assert.notEqual(data[0].invitations.indexOf('invite1@test.com'), -1); | 					var adminData = data.filter(d => parseInt(d.uid, 10) === adminUid)[0]; | ||||||
| 				assert.notEqual(data[0].invitations.indexOf('invite2@test.com'), -1); | 					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(); | 					done(); | ||||||
| 				}); | 				}); | ||||||
| 			}); | 			}); | ||||||
| @@ -2023,7 +2165,7 @@ describe('User', function () { | |||||||
|  |  | ||||||
| 			it('should verify installation with no errors', function (done) { | 			it('should verify installation with no errors', function (done) { | ||||||
| 				var email = 'invite1@test.com'; | 				var email = 'invite1@test.com'; | ||||||
| 			db.get('invitation:email:' + email, function (err, token) { | 				db.getObjectField('invitation:email:' + email, 'token', function (err, token) { | ||||||
| 					assert.ifError(err); | 					assert.ifError(err); | ||||||
| 					User.verifyInvitation({ token: token, email: 'invite1@test.com' }, function (err) { | 					User.verifyInvitation({ token: token, email: 'invite1@test.com' }, function (err) { | ||||||
| 						assert.ifError(err); | 						assert.ifError(err); | ||||||
| @@ -2052,12 +2194,12 @@ describe('User', function () { | |||||||
| 			}); | 			}); | ||||||
|  |  | ||||||
| 			it('should delete invitation key', function (done) { | 			it('should delete invitation key', function (done) { | ||||||
| 			User.deleteInvitationKey('invite2@test.com', function (err) { | 				User.deleteInvitationKey('invite99@test.com', function (err) { | ||||||
| 					assert.ifError(err); | 					assert.ifError(err); | ||||||
| 				db.isSetMember('invitation:uid:' + inviterUid, 'invite2@test.com', function (err, isMember) { | 					db.isSetMember('invitation:uid:' + adminUid, 'invite99@test.com', function (err, isMember) { | ||||||
| 						assert.ifError(err); | 						assert.ifError(err); | ||||||
| 						assert.equal(isMember, false); | 						assert.equal(isMember, false); | ||||||
| 					db.isSetMember('invitation:uids', inviterUid, function (err, isMember) { | 						db.isSetMember('invitation:uids', adminUid, function (err, isMember) { | ||||||
| 							assert.ifError(err); | 							assert.ifError(err); | ||||||
| 							assert.equal(isMember, false); | 							assert.equal(isMember, false); | ||||||
| 							done(); | 							done(); | ||||||
| @@ -2066,16 +2208,89 @@ describe('User', function () { | |||||||
| 				}); | 				}); | ||||||
| 			}); | 			}); | ||||||
|  |  | ||||||
| 		it('should escape email', function (done) { | 			it('should joined the groups from invitation after registration', async function () { | ||||||
| 			socketUser.invite({ uid: inviterUid }, '<script>alert("ok");</script>', function (err) { | 				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(); | ||||||
|  | 					}); | ||||||
|  | 				}); | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		describe('invite groups', () => { | ||||||
|  | 			var csrf_token; | ||||||
|  | 			var jar; | ||||||
|  |  | ||||||
|  | 			before(function (done) { | ||||||
|  | 				helpers.loginUser('inviter', COMMON_PW, function (err, _jar) { | ||||||
| 					assert.ifError(err); | 					assert.ifError(err); | ||||||
| 				User.getInvites(inviterUid, function (err, data) { | 					jar = _jar; | ||||||
|  |  | ||||||
|  | 					request({ | ||||||
|  | 						url: nconf.get('url') + '/api/config', | ||||||
|  | 						json: true, | ||||||
|  | 						jar: jar, | ||||||
|  | 					}, function (err, response, body) { | ||||||
| 						assert.ifError(err); | 						assert.ifError(err); | ||||||
| 					assert.equal(data[0], '<script>alert("ok");</script>'); | 						csrf_token = body.csrf_token; | ||||||
| 						done(); | 						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: {}, | ||||||
|  | 				}); | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	describe('email confirm', function () { | 	describe('email confirm', function () { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user