diff --git a/install/package.json b/install/package.json index 0186eff62e..701dcc75bc 100644 --- a/install/package.json +++ b/install/package.json @@ -107,10 +107,10 @@ "nodebb-plugin-ntfy": "1.7.7", "nodebb-plugin-spam-be-gone": "2.2.2", "nodebb-rewards-essentials": "1.0.0", - "nodebb-theme-harmony": "1.2.78", + "nodebb-theme-harmony": "1.2.79", "nodebb-theme-lavender": "7.1.10", "nodebb-theme-peace": "2.2.8", - "nodebb-theme-persona": "13.3.41", + "nodebb-theme-persona": "13.3.42", "nodebb-widget-essentials": "7.0.30", "nodemailer": "6.9.16", "nprogress": "0.2.0", diff --git a/public/language/en-GB/admin/manage/user-custom-fields.json b/public/language/en-GB/admin/manage/user-custom-fields.json new file mode 100644 index 0000000000..ea0f18361a --- /dev/null +++ b/public/language/en-GB/admin/manage/user-custom-fields.json @@ -0,0 +1,21 @@ +{ + "title": "Manage Custom User Fields", + "create-field": "Create Field", + "edit-field": "Edit Field", + "manage-custom-fields": "Manage Custom Fields", + "type-of-input": "Type of input", + "key": "Key", + "name": "Name", + "type": "Type", + "min-rep": "Minimum Reputation", + "input-type-text": "Input (Text)", + "input-type-link": "Input (Link)", + "input-type-number": "Input (Number)", + "input-type-select": "Select", + "select-options": "Options", + "select-options-help": "Add one option per line for the select element", + "minimum-reputation": "Minimum reputation", + "minimum-reputation-help": "If a user has less than this value they won't be able to use this field", + "delete-field-confirm-x": "Do you really want to delete custom field \"%1\"?", + "custom-fields-saved": "Custom fields saved" +} \ No newline at end of file diff --git a/public/language/en-GB/admin/manage/users.json b/public/language/en-GB/admin/manage/users.json index d11670719c..6cd6a14aef 100644 --- a/public/language/en-GB/admin/manage/users.json +++ b/public/language/en-GB/admin/manage/users.json @@ -22,6 +22,7 @@ "delete-content": "Delete User(s) Content", "purge": "Delete User(s) and Content", "download-csv": "Download CSV", + "custom-user-fields": "Custom User Fields", "manage-groups": "Manage Groups", "set-reputation": "Set Reputation", "add-group": "Add Group", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index c8d52acb6e..c12630d35b 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -208,6 +208,11 @@ "not-enough-reputation-min-rep-signature": "You need %1 reputation to add a signature", "not-enough-reputation-min-rep-profile-picture": "You need %1 reputation to add a profile picture", "not-enough-reputation-min-rep-cover-picture": "You need %1 reputation to add a cover picture", + "not-enough-reputation-custom-field": "You need %1 reputation for %2", + "custom-user-field-value-too-long": "Custom field value too long, %1", + "custom-user-field-select-value-invalid": "Custom field selected option is invalid, %1", + "custom-user-field-invalid-link": "Custom field link is invalid, %1", + "custom-user-field-invalid-number": "Custom field number is invalid, %1", "post-already-flagged": "You have already flagged this post", "user-already-flagged": "You have already flagged this user", "post-flagged-too-many-times": "This post has been flagged by others already", diff --git a/public/openapi/read.yaml b/public/openapi/read.yaml index 427026ecf8..f6104b495e 100644 --- a/public/openapi/read.yaml +++ b/public/openapi/read.yaml @@ -106,6 +106,8 @@ paths: $ref: 'read/admin/manage/tags.yaml' /api/admin/manage/users: $ref: 'read/admin/manage/users.yaml' + /api/admin/manage/users/custom-fields: + $ref: 'read/admin/manage/users/custom-fields.yaml' /api/admin/manage/registration: $ref: 'read/admin/manage/registration.yaml' /api/admin/manage/admins-mods: diff --git a/public/openapi/read/admin/manage/users/custom-fields.yaml b/public/openapi/read/admin/manage/users/custom-fields.yaml new file mode 100644 index 0000000000..68c99e0ef2 --- /dev/null +++ b/public/openapi/read/admin/manage/users/custom-fields.yaml @@ -0,0 +1,27 @@ +get: + tags: + - admin + summary: Manage custom fields for users + responses: + "200": + description: "" + content: + application/json: + schema: + allOf: + - type: object + properties: + fields: + type: array + items: + type: object + properties: + key: + type: string + name: + type: string + select-options: + type: string + type: + type: string + - $ref: ../../../../components/schemas/CommonProps.yaml#/CommonProps \ No newline at end of file diff --git a/public/openapi/read/user/userslug.yaml b/public/openapi/read/user/userslug.yaml index 4858383cd3..6d8a1d0701 100644 --- a/public/openapi/read/user/userslug.yaml +++ b/public/openapi/read/user/userslug.yaml @@ -31,6 +31,8 @@ get: type: string allowCoverPicture: type: boolean + customUserFields: + type: array selectedGroup: type: array items: diff --git a/public/openapi/read/user/userslug/edit.yaml b/public/openapi/read/user/userslug/edit.yaml index 8ba486b5e8..e7fdb560a0 100644 --- a/public/openapi/read/user/userslug/edit.yaml +++ b/public/openapi/read/user/userslug/edit.yaml @@ -66,6 +66,8 @@ get: type: number title: type: string + customUserFields: + type: array editButtons: type: array items: diff --git a/public/src/admin/manage/users/custom-fields.js b/public/src/admin/manage/users/custom-fields.js new file mode 100644 index 0000000000..050642ca41 --- /dev/null +++ b/public/src/admin/manage/users/custom-fields.js @@ -0,0 +1,100 @@ +define('admin/manage/user/custom-fields', [ + 'bootbox', 'alerts', 'jquery-ui/widgets/sortable', +], function (bootbox, alerts) { + const manageUserFields = {}; + + manageUserFields.init = function () { + const table = $('table'); + + table.on('click', '[data-action="edit"]', function () { + const row = $(this).parents('[data-key]'); + showModal(getDataFromEl(row)); + }); + + table.on('click', '[data-action="delete"]', function () { + const key = $(this).attr('data-key'); + const row = $(this).parents('[data-key]'); + bootbox.confirm(`[[admin/manage/user-custom-fields:delete-field-confirm-x, ${key}]]`, function (ok) { + if (!ok) { + return; + } + row.remove(); + }); + }); + + $('tbody').sortable({ + handle: '[component="sort/handle"]', + axis: 'y', + zIndex: 9999, + }); + + $('#new').on('click', () => showModal()); + + $('#save').on('click', () => { + const fields = []; + $('tbody tr[data-key]').each((index, el) => { + fields.push(getDataFromEl($(el))); + }); + socket.emit('admin.user.saveCustomFields', fields, function (err) { + if (err) { + alerts.error(err); + } + alerts.success('[[admin/manage/user-custom-fields:custom-fields-saved]]'); + }); + }); + }; + + function getDataFromEl(el) { + return { + key: el.attr('data-key'), + name: el.attr('data-name'), + type: el.attr('data-type'), + 'select-options': el.attr('data-select-options'), + 'min:rep': el.attr('data-min-rep'), + }; + } + + async function showModal(field = null) { + const html = await app.parseAndTranslate('admin/partials/manage-custom-user-fields-modal', field); + + const modal = bootbox.dialog({ + message: html, + onEscape: true, + title: field ? + '[[admin/manage/user-custom-fields:edit-field]]' : + '[[admin/manage/user-custom-fields:create-field]]', + buttons: { + submit: { + label: '[[global:save]]', + callback: function () { + const formData = modal.find('form').serializeObject(); + if (formData.type === 'select') { + formData.selectOptionsFormatted = formData['select-options'].trim().split('\n').join(', '); + } + + app.parseAndTranslate('admin/manage/users/custom-fields', 'fields', { + fields: [formData], + }, (html) => { + if (field) { + const oldKey = field.key; + $(`tbody [data-key="${oldKey}"]`).replaceWith(html); + } else { + $('tbody').append(html); + } + }); + }, + }, + }, + }); + + modal.find('#type-select').on('change', function () { + const type = $(this).val(); + modal.find(`[data-input-type]`).addClass('hidden'); + modal.find(`[data-input-type="${type}"]`).removeClass('hidden'); + }); + } + + return manageUserFields; +}); + + diff --git a/src/controllers/accounts/edit.js b/src/controllers/accounts/edit.js index 599f898c24..c687279698 100644 --- a/src/controllers/accounts/edit.js +++ b/src/controllers/accounts/edit.js @@ -7,6 +7,7 @@ const groups = require('../../groups'); const privileges = require('../../privileges'); const plugins = require('../../plugins'); const file = require('../../file'); +const accountHelpers = require('./helpers'); const editController = module.exports; @@ -25,11 +26,13 @@ editController.get = async function (req, res, next) { allowMultipleBadges, } = userData; - const [canUseSignature, canManageUsers] = await Promise.all([ + const [canUseSignature, canManageUsers, customUserFields] = await Promise.all([ privileges.global.can('signature', req.uid), privileges.admin.can('admin:users', req.uid), + accountHelpers.getCustomUserFields(userData), ]); + userData.customUserFields = customUserFields; userData.maximumSignatureLength = meta.config.maximumSignatureLength; userData.maximumAboutMeLength = meta.config.maximumAboutMeLength; userData.maximumProfileImageSize = meta.config.maximumProfileImageSize; diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index 6b37bf6bbd..b677bc2ed3 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -134,6 +134,29 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {}) return hookData.userData; }; +helpers.getCustomUserFields = async function (userData) { + const keys = await db.getSortedSetRange('user-custom-fields', 0, -1); + const allFields = (await db.getObjects(keys.map(k => `user-custom-field:${k}`))).filter(Boolean); + + const fields = allFields.filter((field) => { + const minRep = field['min:rep'] || 0; + return userData.reputation >= minRep || meta.config['reputation:disabled']; + }); + + fields.forEach((f) => { + f['select-options'] = f['select-options'].split('\n').filter(Boolean).map( + opt => ({ + value: opt, + selected: opt === userData[f.key], + }) + ); + if (userData[f.key]) { + f.value = validator.escape(String(userData[f.key])); + } + }); + return fields; +}; + function escape(value) { return translator.escape(validator.escape(String(value || ''))); } diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index 9a2c349916..f99b1f9fcd 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -9,6 +9,7 @@ const categories = require('../../categories'); const plugins = require('../../plugins'); const privileges = require('../../privileges'); const helpers = require('../helpers'); +const accountHelpers = require('./helpers'); const utils = require('../../utils'); const profileController = module.exports; @@ -21,12 +22,13 @@ profileController.get = async function (req, res, next) { await incrementProfileViews(req, userData); - const [latestPosts, bestPosts] = await Promise.all([ + const [latestPosts, bestPosts, customUserFields] = await Promise.all([ getLatestPosts(req.uid, userData), getBestPosts(req.uid, userData), + accountHelpers.getCustomUserFields(userData), posts.parseSignature(userData, req.uid), ]); - + userData.customUserFields = customUserFields; userData.posts = latestPosts; // for backwards compat. userData.latestPosts = latestPosts; userData.bestPosts = bestPosts; diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index aab9045eca..a372342640 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -294,3 +294,15 @@ usersController.getCSV = async function (req, res, next) { } }); }; + +usersController.customFields = async function (req, res) { + const keys = await db.getSortedSetRange('user-custom-fields', 0, -1); + const fields = (await db.getObjects(keys.map(k => `user-custom-field:${k}`))).filter(Boolean); + fields.forEach((field) => { + if (field['select-options']) { + field.selectOptionsFormatted = field['select-options'].trim().split('\n').join(', '); + } + field['min:rep'] = field['min:rep'] || 0; + }); + res.render('admin/manage/users/custom-fields', { fields: fields }); +}; diff --git a/src/privileges/admin.js b/src/privileges/admin.js index 35a71e5f02..958b4cfe49 100644 --- a/src/privileges/admin.js +++ b/src/privileges/admin.js @@ -96,6 +96,9 @@ privsAdmin.socketMap = { 'admin.user.removeAdmins': 'admin:admins-mods', 'admin.user.loadGroups': 'admin:users', + 'admin.user.addCustomField': 'admin:users', + 'admin.user.editCustomField': 'admin:users', + 'admin.user.deleteCustomField': 'admin:users', 'admin.groups.join': 'admin:users', 'admin.groups.leave': 'admin:users', 'admin.user.resetLockouts': 'admin:users', diff --git a/src/routes/admin.js b/src/routes/admin.js index 6e6721c13e..9e780d9104 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -21,6 +21,7 @@ module.exports = function (app, name, middleware, controllers) { helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get); helpers.setupAdminPageRoute(app, `/${name}/manage/users`, middlewares, controllers.admin.users.index); + helpers.setupAdminPageRoute(app, `/${name}/manage/users/custom-fields`, middlewares, controllers.admin.users.customFields); helpers.setupAdminPageRoute(app, `/${name}/manage/registration`, middlewares, controllers.admin.users.registrationQueue); helpers.setupAdminPageRoute(app, `/${name}/manage/admins-mods`, middlewares, controllers.admin.adminsMods.get); diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index db9a49ac1f..ee2215e4ec 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -187,3 +187,20 @@ User.exportUsersCSV = async function (socket, data) { } }, 0); }; + +User.saveCustomFields = async function (socket, fields) { + const keys = await db.getSortedSetRange('user-custom-fields', 0, -1); + await db.delete('user-custom-fields'); + await db.deleteAll(keys.map(k => `user-custom-field:${k}`)); + + await db.sortedSetAdd( + `user-custom-fields`, + fields.map((f, i) => i), + fields.map(f => f.key) + ); + await db.setObjectBulk( + fields.map(field => [`user-custom-field:${field.key}`, field]) + ); + await user.reloadCustomFieldWhitelist(); +}; + diff --git a/src/user/data.js b/src/user/data.js index d0940ff98e..d86a099dfd 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -28,6 +28,8 @@ module.exports = function (User) { 'cover:position', 'groupTitle', 'mutedUntil', 'mutedReason', ]; + let customFieldWhiteList = null; + User.guestData = { uid: 0, username: '[[global:guest]]', @@ -46,6 +48,10 @@ module.exports = function (User) { let iconBackgrounds; + User.reloadCustomFieldWhitelist = async () => { + customFieldWhiteList = await db.getSortedSetRange('user-custom-fields', 0, -1); + }; + User.getUsersFields = async function (uids, fields) { if (!Array.isArray(uids) || !uids.length) { return []; @@ -58,10 +64,12 @@ module.exports = function (User) { ensureRequiredFields(fields, fieldsToRemove); const uniqueUids = _.uniq(uids).filter(uid => uid > 0); - + if (!customFieldWhiteList) { + await User.reloadCustomFieldWhitelist(); + } const results = await plugins.hooks.fire('filter:user.whitelistFields', { uids: uids, - whitelist: fieldWhitelist.slice(), + whitelist: _.uniq(fieldWhitelist.concat(customFieldWhiteList)), }); if (!fields.length) { fields = results.whitelist; diff --git a/src/user/profile.js b/src/user/profile.js index e9c751e40f..eefd442eb6 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -11,12 +11,14 @@ const meta = require('../meta'); const db = require('../database'); const groups = require('../groups'); const plugins = require('../plugins'); +const tx = require('../translator'); module.exports = function (User) { User.updateProfile = async function (uid, data, extraFields) { let fields = [ 'username', 'email', 'fullname', 'website', 'location', 'groupTitle', 'birthday', 'signature', 'aboutme', + ...await db.getSortedSetRange('user-custom-fields', 0, -1), ]; if (Array.isArray(extraFields)) { fields = _.uniq(fields.concat(extraFields)); @@ -82,6 +84,49 @@ module.exports = function (User) { isLocationValid(data); isBirthdayValid(data); isGroupTitleValid(data); + await validateCustomFields(data); + } + + async function validateCustomFields(data) { + const keys = await db.getSortedSetRange('user-custom-fields', 0, -1); + const fields = (await db.getObjects(keys.map(k => `user-custom-field:${k}`))).filter(Boolean); + const reputation = await User.getUserField(data.uid, 'reputation'); + + fields.forEach((field) => { + const { key, type } = field; + if (data.hasOwnProperty(key)) { + const value = data[key]; + const minRep = field['min:rep'] || 0; + if (reputation < minRep && !meta.config['reputation:disabled']) { + throw new Error(tx.compile( + 'error:not-enough-reputation-custom-field', minRep, field.name + )); + } + + if (typeof value === 'string' && value.length > 255) { + throw new Error(tx.compile( + 'error:custom-user-field-value-too-long', field.name + )); + } + + if (type === 'input-number' && !utils.isNumber(value)) { + throw new Error(tx.compile( + 'error:custom-user-field-invalid-number', field.name + )); + } else if (value && field.type === 'input-link' && !validator.isURL(String(value))) { + throw new Error(tx.compile( + 'error:custom-user-field-invalid-link', field.name + )); + } else if (field.type === 'select') { + const opts = field['select-options'].split('\n').filter(Boolean); + if (!opts.includes(value)) { + throw new Error(tx.compile( + 'error:custom-user-field-select-value-invalid', field.name + )); + } + } + } + }); } async function isEmailValid(data) { diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index 6d8dfca9ea..fb80f94b0f 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -81,6 +81,8 @@
  • [[admin/manage/users:create]]
  • {{{ if showInviteButton }}}
  • [[admin/manage/users:invite]]
  • {{{ end }}}
  • [[admin/manage/users:download-csv]]
  • +
  • [[admin/manage/users:custom-user-fields]] +
  • diff --git a/src/views/admin/manage/users/custom-fields.tpl b/src/views/admin/manage/users/custom-fields.tpl new file mode 100644 index 0000000000..426a1d07fc --- /dev/null +++ b/src/views/admin/manage/users/custom-fields.tpl @@ -0,0 +1,60 @@ +
    +
    +
    +

    [[admin/manage/user-custom-fields:title]]

    +
    +
    + + +
    +
    + +
    +
    +
    + + + + + + + + + + + + + {{{ each fields }}} + + + + + + + + + {{{ end }}} + +
    [[admin/manage/user-custom-fields:key]][[admin/manage/user-custom-fields:name]][[admin/manage/user-custom-fields:type]][[admin/manage/user-custom-fields:min-rep]]
    + + {./key}{./name} + {./type} + {{{ if (./type == "select") }}} +
    + ({./selectOptionsFormatted}) +
    + {{{ end }}} +
    + {./min:rep} + +
    + + +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/views/admin/partials/manage-custom-user-fields-modal.tpl b/src/views/admin/partials/manage-custom-user-fields-modal.tpl new file mode 100644 index 0000000000..0fed91e6c5 --- /dev/null +++ b/src/views/admin/partials/manage-custom-user-fields-modal.tpl @@ -0,0 +1,33 @@ +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +

    [[admin/manage/user-custom-fields:minimum-reputation-help]]

    +
    + +
    + + +

    [[admin/manage/user-custom-fields:select-options-help]]

    +
    +