Squashed commit of the following:

commit afd96a00b1
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Nov 7 10:42:33 2024 -0500

    chore: up themes

commit b40e530434
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Wed Nov 6 19:16:44 2024 -0500

    feat: add min:rep to custom fields

    add validation in profile.update

commit 4b5cb613e5
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Wed Nov 6 12:03:22 2024 -0500

    test: add openapi spec, move menu button

commit 0c551fa240
Merge: 13f39053c9 bc00df3cd9
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Wed Nov 6 11:48:05 2024 -0500

    Merge branch 'develop' into custom-user-fields

commit 13f39053c9
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Wed Jul 31 00:23:39 2024 -0400

    refactor: dont need delete function

commit f33c8849d8
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Tue Jul 30 21:30:00 2024 -0400

    feat: show custom fields on edit/profile

commit 5e1d8769d4
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Tue Jul 30 17:08:25 2024 -0400

    feat: add custom user fields acp page
This commit is contained in:
Barış Soner Uşaklı
2024-11-07 10:43:10 -05:00
parent 79b1922bbd
commit bbf66c243b
21 changed files with 376 additions and 7 deletions

View File

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

View File

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

View File

@@ -22,6 +22,7 @@
"delete-content": "Delete User(s) <strong>Content</strong>",
"purge": "Delete <strong>User(s)</strong> and <strong>Content</strong>",
"download-csv": "Download CSV",
"custom-user-fields": "Custom User Fields",
"manage-groups": "Manage Groups",
"set-reputation": "Set Reputation",
"add-group": "Add Group",

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,8 @@ get:
type: string
allowCoverPicture:
type: boolean
customUserFields:
type: array
selectedGroup:
type: array
items:

View File

@@ -66,6 +66,8 @@ get:
type: number
title:
type: string
customUserFields:
type: array
editButtons:
type: array
items:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,8 @@
<li><a class="dropdown-item rounded-1" href="#" data-action="create" role="menuitem">[[admin/manage/users:create]]</a></li>
{{{ if showInviteButton }}}<li><a class="dropdown-item rounded-1" href="#" component="user/invite" role="menuitem">[[admin/manage/users:invite]]</a></li>{{{ end }}}
<li><a target="_blank" href="#" class="dropdown-item rounded-1 export-csv" role="menuitem">[[admin/manage/users:download-csv]]</a></li>
<li><a class="dropdown-item rounded-1" href="{relative_path}/admin/manage/users/custom-fields">[[admin/manage/users:custom-user-fields]]</a>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,60 @@
<div class="manage-users d-flex flex-column gap-2 px-lg-4 h-100">
<div class="d-flex border-bottom py-2 m-0 sticky-top acp-page-main-header align-items-center justify-content-between flex-wrap gap-2">
<div class="">
<h4 class="fw-bold tracking-tight mb-0">[[admin/manage/user-custom-fields:title]]</h4>
</div>
<div class="d-flex align-items-center gap-1">
<button id="new" class="btn btn-light btn-sm text-nowrap" type="button">
<i class="fa fa-fw fa-plus"></i> [[admin/manage/user-custom-fields:create-field]]
</button>
<button id="save" class="btn btn-primary btn-sm fw-semibold ff-secondary w-100 text-center text-nowrap">[[admin/admin:save-changes]]</button>
</div>
</div>
<div class="row flex-grow-1">
<div class="col-lg-12 d-flex flex-column gap-2">
<div class="table-responsive flex-grow-1">
<table class="table text-sm">
<thead>
<tr>
<th></th>
<th class="text-muted">[[admin/manage/user-custom-fields:key]]</th>
<th class="text-muted">[[admin/manage/user-custom-fields:name]]</th>
<th class="text-muted">[[admin/manage/user-custom-fields:type]]</th>
<th class="text-muted text-end">[[admin/manage/user-custom-fields:min-rep]]</th>
<th></th>
</tr>
</thead>
<tbody>
{{{ each fields }}}
<tr data-key="{./key}" data-name="{./name}" data-type="{./type}" data-min-rep="{./min:rep}" data-select-options="{./select-options}" class="align-middle">
<td style="width: 32px;">
<a href="#" component="sort/handle" class="btn btn-light btn-sm d-none d-md-block ui-sortable-handle" style="cursor:grab;"><i class="fa fa-arrows-up-down text-muted"></i></a>
</td>
<td class="text-nowrap">{./key}</td>
<td class="text-nowrap">{./name}</td>
<td>
{./type}
{{{ if (./type == "select") }}}
<div class="text-muted">
({./selectOptionsFormatted})
</div>
{{{ end }}}
</td>
<td class="text-end">
{./min:rep}
</td>
<td>
<div class="d-flex justify-content-end gap-1">
<button data-action="edit" data-key="{./key}" class="btn btn-light btn-sm">[[admin/admin:edit]]</button>
<button data-action="delete" data-key="{./key}" class="btn btn-light btn-sm"><i class="fa fa-trash text-danger"></i></button>
</div>
</td>
</tr>
{{{ end }}}
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
<form>
<div class="mb-3">
<label class="form-label">[[admin/manage/user-custom-fields:type-of-input]]</label>
<select class="form-select" id="type-select" name="type">
<option value="input-text" {{{ if (type == "input-text") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-text]]</option>
<option value="input-link" {{{ if (type == "input-link") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-link]]</option>
<option value="input-number" {{{ if (type == "input-number") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-number]]</option>
<option value="select" {{{ if (type == "select") }}}selected{{{ end }}}>[[admin/manage/user-custom-fields:input-type-select]]</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">[[admin/manage/user-custom-fields:key]]</label>
<input class="form-control" type="text" name="key" value="{./key}">
</div>
<div class="mb-3">
<label class="form-label">[[admin/manage/user-custom-fields:name]]</label>
<input class="form-control" type="text" name="name" value="{./name}">
</div>
<div class="mb-3">
<label class="form-label">[[admin/manage/user-custom-fields:minimum-reputation]]</label>
<input class="form-control" type="number" name="min:rep" value="{./min:rep}" placeholder="0">
<p class="form-text">[[admin/manage/user-custom-fields:minimum-reputation-help]]</p>
</div>
<div class="mb-3 {{{ if (type != "select") }}}hidden{{{ end }}}" data-input-type="select">
<label class="form-label">[[admin/manage/user-custom-fields:select-options]]</label>
<textarea class="form-control" name="select-options" rows="6">{./select-options}</textarea>
<p class="form-text">[[admin/manage/user-custom-fields:select-options-help]]</p>
</div>
</form>