mirror of
				https://github.com/NodeBB/NodeBB.git
				synced 2025-10-31 19:15:58 +01:00 
			
		
		
		
	feat: cli user management commands (#9848)
* feat: cli user management commands * fix: consistent nomenclature
This commit is contained in:
		| @@ -8,17 +8,13 @@ const { Command, Help } = require('commander'); | ||||
|  | ||||
| const colors = [ | ||||
| 	// depth = 0, top-level command | ||||
| 	{ | ||||
| 		command: 'yellow', | ||||
| 		option: 'cyan', | ||||
| 		arg: 'magenta', | ||||
| 	}, | ||||
| 	{ command: 'yellow', option: 'cyan', arg: 'magenta' }, | ||||
| 	// depth = 1, second-level commands | ||||
| 	{ | ||||
| 		command: 'green', | ||||
| 		option: 'blue', | ||||
| 		arg: 'red', | ||||
| 	}, | ||||
| 	{ command: 'green', option: 'blue', arg: 'red' }, | ||||
| 	// depth = 2, third-level commands | ||||
| 	{ command: 'yellow', option: 'cyan', arg: 'magenta' }, | ||||
| 	// depth = 3 fourth-level commands | ||||
| 	{ command: 'green', option: 'blue', arg: 'red' }, | ||||
| ]; | ||||
|  | ||||
| function humanReadableArgName(arg) { | ||||
|   | ||||
| @@ -253,6 +253,10 @@ resetCommand | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| // user | ||||
| program | ||||
| 	.addCommand(require('./user')()); | ||||
|  | ||||
| // upgrades | ||||
| program | ||||
| 	.command('upgrade [scripts...]') | ||||
|   | ||||
							
								
								
									
										310
									
								
								src/cli/user.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										310
									
								
								src/cli/user.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,310 @@ | ||||
| 'use strict'; | ||||
|  | ||||
| const { Command, Option } = require('commander'); | ||||
|  | ||||
| module.exports = () => { | ||||
| 	const userCmd = new Command('user') | ||||
| 		.description('Manage users') | ||||
| 		.arguments('[command]'); | ||||
|  | ||||
| 	userCmd.configureHelp(require('./colors')); | ||||
| 	const userCommands = UserCommands(); | ||||
|  | ||||
| 	userCmd | ||||
| 		.command('info') | ||||
| 		.description('Display user info by uid/username/userslug.') | ||||
| 		.option('-i, --uid <uid>', 'Retrieve user by uid') | ||||
| 		.option('-u, --username <username>', 'Retrieve user by username') | ||||
| 		.option('-s, --userslug <userslug>', 'Retrieve user by userslug') | ||||
| 		.action((...args) => execute(userCommands.info, args)); | ||||
| 	userCmd | ||||
| 		.command('create') | ||||
| 		.description('Create a new user.') | ||||
| 		.arguments('<username>') | ||||
| 		.option('-p, --password <password>', 'Set a new password. (Auto-generates if omitted)') | ||||
| 		.option('-e, --email <email>', 'Associate with an email.') | ||||
| 		.action((...args) => execute(userCommands.create, args)); | ||||
| 	userCmd | ||||
| 		.command('reset') | ||||
| 		.description('Reset a user\'s password or send a password reset email.') | ||||
| 		.arguments('<uid>') | ||||
| 		.option('-p, --password <password>', 'Set a new password. (Auto-generates if passed empty)', false) | ||||
| 		.option('-s, --send-reset-email', 'Send a password reset email.', false) | ||||
| 		.action((...args) => execute(userCommands.reset, args)); | ||||
| 	userCmd | ||||
| 		.command('delete') | ||||
| 		.description('Delete user(s) and/or their content') | ||||
| 		.arguments('<uids...>') | ||||
| 		.addOption( | ||||
| 			new Option('-c, --content [operation]', 'Delete user content ([purge]), leave content ([account]), or delete content only ([content])') | ||||
| 				.choices(['purge', 'account', 'content']).default('purge') | ||||
| 		) | ||||
| 		.action((...args) => execute(userCommands.deleteUser, args)); | ||||
|  | ||||
| 	const make = userCmd.command('make') | ||||
| 		.description('Make user(s) admin, global mod, moderator or a regular user.') | ||||
| 		.arguments('[command]'); | ||||
|  | ||||
| 	make.command('admin') | ||||
| 		.description('Make user(s) an admin') | ||||
| 		.arguments('<uids...>') | ||||
| 		.action((...args) => execute(userCommands.makeAdmin, args)); | ||||
| 	make.command('global-mod') | ||||
| 		.description('Make user(s) a global moderator') | ||||
| 		.arguments('<uids...>') | ||||
| 		.action((...args) => execute(userCommands.makeGlobalMod, args)); | ||||
| 	make.command('mod') | ||||
| 		.description('Make uid(s) of user(s) moderator of given category IDs (cids)') | ||||
| 		.arguments('<uids...>') | ||||
| 		.requiredOption('-c, --cid <cids...>', 'ID(s) of categories to make the user a moderator of') | ||||
| 		.action((...args) => execute(userCommands.makeMod, args)); | ||||
| 	make.command('regular') | ||||
| 		.description('Make user(s) a non-privileged user') | ||||
| 		.arguments('<uids...>') | ||||
| 		.action((...args) => execute(userCommands.makeRegular, args)); | ||||
|  | ||||
| 	return userCmd; | ||||
| }; | ||||
|  | ||||
| let db; | ||||
| let user; | ||||
| let groups; | ||||
| let privileges; | ||||
| let privHelpers; | ||||
| let utils; | ||||
| let winston; | ||||
|  | ||||
| async function init() { | ||||
| 	db = require('../database'); | ||||
| 	await db.init(); | ||||
|  | ||||
| 	user = require('../user'); | ||||
| 	groups = require('../groups'); | ||||
| 	privileges = require('../privileges'); | ||||
| 	privHelpers = require('../privileges/helpers'); | ||||
| 	utils = require('../utils'); | ||||
| 	winston = require('winston'); | ||||
| } | ||||
|  | ||||
| async function execute(cmd, args) { | ||||
| 	await init(); | ||||
| 	try { | ||||
| 		await cmd(...args); | ||||
| 	} catch (err) { | ||||
| 		const userError = err.name === 'UserError'; | ||||
| 		winston.error(`[userCmd/${cmd.name}] ${userError ? `${err.message}` : 'Command failed.'}`, userError ? '' : err); | ||||
| 		process.exit(1); | ||||
| 	} | ||||
|  | ||||
| 	process.exit(); | ||||
| } | ||||
|  | ||||
| function UserCmdHelpers() { | ||||
| 	async function getAdminUidOrFail() { | ||||
| 		const adminUid = (await db.getSortedSetMembers('group:administrators:members')).reverse()[0]; | ||||
| 		if (!adminUid) { | ||||
| 			const err = new Error('An admin account does not exists to execute the operation.'); | ||||
| 			err.name = 'UserError'; | ||||
| 			throw err; | ||||
| 		} | ||||
| 		return adminUid; | ||||
| 	} | ||||
|  | ||||
| 	async function setupApp() { | ||||
| 		const nconf = require('nconf'); | ||||
| 		const Benchpress = require('benchpressjs'); | ||||
|  | ||||
| 		const meta = require('../meta'); | ||||
| 		await meta.configs.init(); | ||||
|  | ||||
| 		const webserver = require('../webserver'); | ||||
| 		const viewsDir = nconf.get('views_dir'); | ||||
|  | ||||
| 		webserver.app.engine('tpl', (filepath, data, next) => { | ||||
| 			filepath = filepath.replace(/\.tpl$/, '.js'); | ||||
|  | ||||
| 			Benchpress.__express(filepath, data, next); | ||||
| 		}); | ||||
| 		webserver.app.set('view engine', 'tpl'); | ||||
| 		webserver.app.set('views', viewsDir); | ||||
|  | ||||
| 		const emailer = require('../emailer'); | ||||
| 		emailer.registerApp(webserver.app); | ||||
| 	} | ||||
|  | ||||
| 	const argParsers = { | ||||
| 		intParse: (value, varName) => { | ||||
| 			const parsedValue = parseInt(value, 10); | ||||
| 			if (isNaN(parsedValue)) { | ||||
| 				const err = new Error(`"${varName}" expected to be a number.`); | ||||
| 				err.name = 'UserError'; | ||||
| 				throw err; | ||||
| 			} | ||||
| 			return parsedValue; | ||||
| 		}, | ||||
| 		intArrayParse: (values, varName) => values.map(value => argParsers.intParse(value, varName)), | ||||
| 	}; | ||||
|  | ||||
| 	return { | ||||
| 		argParsers, | ||||
| 		getAdminUidOrFail, | ||||
| 		setupApp, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function UserCommands() { | ||||
| 	const { argParsers, getAdminUidOrFail, setupApp } = UserCmdHelpers(); | ||||
|  | ||||
| 	async function info({ uid, username, userslug }) { | ||||
| 		if (!uid && !username && !userslug) { | ||||
| 			return winston.error('[userCmd/info] At least one option has to be passed (--uid, --username or --userslug).'); | ||||
| 		} | ||||
|  | ||||
| 		if (uid) { | ||||
| 			uid = argParsers.intParse(uid, 'uid'); | ||||
| 		} else if (username) { | ||||
| 			uid = await user.getUidByUsername(username); | ||||
| 		} else { | ||||
| 			uid = await user.getUidByUserslug(userslug); | ||||
| 		} | ||||
|  | ||||
| 		const userData = await user.getUserData(uid); | ||||
| 		winston.info('[userCmd/info] User info retrieved:'); | ||||
| 		console.log(userData); | ||||
| 	} | ||||
|  | ||||
| 	async function create(username, { password, email }) { | ||||
| 		let pwGenerated = false; | ||||
| 		if (password === undefined) { | ||||
| 			password = utils.generateUUID().slice(0, 8); | ||||
| 			pwGenerated = true; | ||||
| 		} | ||||
|  | ||||
| 		const userExists = await user.getUidByUsername(username); | ||||
| 		if (userExists) { | ||||
| 			return winston.error(`[userCmd/create] A user with username '${username}' already exists`); | ||||
| 		} | ||||
|  | ||||
| 		const uid = await user.create({ | ||||
| 			username, | ||||
| 			password, | ||||
| 			email, | ||||
| 		}); | ||||
|  | ||||
| 		winston.info(`[userCmd/create] User '${username}'${password ? '' : ' without a password'} has been created with uid: ${uid}.\ | ||||
| ${pwGenerated ? ` Generated password: ${password}` : ''}`); | ||||
| 	} | ||||
|  | ||||
| 	async function reset(uid, { password, sendResetEmail }) { | ||||
| 		uid = argParsers.intParse(uid, 'uid'); | ||||
|  | ||||
| 		if (password === false && sendResetEmail === false) { | ||||
| 			return winston.error('[userCmd/reset] At least one option has to be passed (--password or --send-reset-email).'); | ||||
| 		} | ||||
|  | ||||
| 		const userExists = await user.exists(uid); | ||||
| 		if (!userExists) { | ||||
| 			return winston.error(`[userCmd/reset] A user with given uid does not exists.`); | ||||
| 		} | ||||
|  | ||||
| 		let pwGenerated = false; | ||||
| 		if (password === '') { | ||||
| 			password = utils.generateUUID().slice(0, 8); | ||||
| 			pwGenerated = true; | ||||
| 		} | ||||
|  | ||||
| 		const adminUid = await getAdminUidOrFail(); | ||||
|  | ||||
| 		if (password) { | ||||
| 			await user.changePassword(adminUid, { | ||||
| 				newPassword: password, | ||||
| 				uid, | ||||
| 			}); | ||||
| 			winston.info(`[userCmd/reset] ${password ? 'User password changed.' : ''}${pwGenerated ? ` Generated password: ${password}` : ''}`); | ||||
| 		} | ||||
|  | ||||
| 		if (sendResetEmail) { | ||||
| 			const userEmail = await user.getUserField(uid, 'email'); | ||||
| 			if (!userEmail) { | ||||
| 				return winston.error('User doesn\'t have an email address to send reset email.'); | ||||
| 			} | ||||
| 			await setupApp(); | ||||
| 			await user.reset.send(userEmail); | ||||
| 			winston.info('[userCmd/reset] Password reset email has been sent.'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async function deleteUser(uids, { content }) { | ||||
| 		uids = argParsers.intArrayParse(uids, 'uids'); | ||||
|  | ||||
| 		const userExists = await user.exists(uids); | ||||
| 		if (!userExists || userExists.some(r => r === false)) { | ||||
| 			return winston.error(`[userCmd/reset] A user with given uid does not exists.`); | ||||
| 		} | ||||
|  | ||||
| 		await db.initSessionStore(); | ||||
| 		const adminUid = await getAdminUidOrFail(); | ||||
|  | ||||
| 		switch (content) { | ||||
| 			case 'purge': | ||||
| 				await Promise.all(uids.map(uid => user.delete(adminUid, uid))); | ||||
| 				winston.info(`[userCmd/delete] User(s) with their content has been deleted.`); | ||||
| 				break; | ||||
| 			case 'account': | ||||
| 				await Promise.all(uids.map(uid => user.deleteAccount(uid))); | ||||
| 				winston.info(`[userCmd/delete] User(s) has been deleted, their content left intact.`); | ||||
| 				break; | ||||
| 			case 'content': | ||||
| 				await Promise.all(uids.map(uid => user.deleteContent(adminUid, uid))); | ||||
| 				winston.info(`[userCmd/delete] User(s)' content has been deleted.`); | ||||
| 				break; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async function makeAdmin(uids) { | ||||
| 		uids = argParsers.intArrayParse(uids, 'uids'); | ||||
| 		await Promise.all(uids.map(uid => groups.join('administrators', uid))); | ||||
|  | ||||
| 		winston.info('[userCmd/make/admin] User(s) added as administrators.'); | ||||
| 	} | ||||
|  | ||||
| 	async function makeGlobalMod(uids) { | ||||
| 		uids = argParsers.intArrayParse(uids, 'uids'); | ||||
| 		await Promise.all(uids.map(uid => groups.join('Global Moderators', uid))); | ||||
|  | ||||
| 		winston.info('[userCmd/make/globalMod] User(s) added as global moderators.'); | ||||
| 	} | ||||
|  | ||||
| 	async function makeMod(uids, { cid: cids }) { | ||||
| 		uids = argParsers.intArrayParse(uids, 'uids'); | ||||
| 		cids = argParsers.intArrayParse(cids, 'cids'); | ||||
|  | ||||
| 		const categoryPrivList = await privileges.categories.getPrivilegeList(); | ||||
| 		await privHelpers.giveOrRescind(groups.join, categoryPrivList, cids, uids); | ||||
|  | ||||
| 		winston.info('[userCmd/make/mod] User(s) added as moderators to given categories.'); | ||||
| 	} | ||||
|  | ||||
| 	async function makeRegular(uids) { | ||||
| 		uids = argParsers.intArrayParse(uids, 'uids'); | ||||
|  | ||||
| 		await Promise.all(uids.map(uid => groups.leave(['administrators', 'Global Moderators'], uid))); | ||||
|  | ||||
| 		const categoryPrivList = await privileges.categories.getPrivilegeList(); | ||||
| 		const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); | ||||
| 		await privHelpers.giveOrRescind(groups.leave, categoryPrivList, cids, uids); | ||||
|  | ||||
| 		winston.info('[userCmd/make/regular] User(s) made regular/non-privileged.'); | ||||
| 	} | ||||
|  | ||||
| 	return { | ||||
| 		info, | ||||
| 		create, | ||||
| 		reset, | ||||
| 		deleteUser, | ||||
| 		makeAdmin, | ||||
| 		makeGlobalMod, | ||||
| 		makeMod, | ||||
| 		makeRegular, | ||||
| 	}; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user