From d8a8626daee59e764d8eb804a96f149c1e6ff6b5 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 24 May 2026 18:31:35 +0200 Subject: [PATCH] update usermanager --- public/s/js/admin.js | 43 ++++++++++++++++++++++++ public/s/js/f0ckm.js | 46 +++++++++++++------------- src/inc/routes/admin.mjs | 65 +++++++++++++++++++++++++++++++++++++ views/admin/users.html | 10 +++--- views/admin/users_list.html | 7 ++-- views/snippets/footer.html | 1 - 6 files changed, 141 insertions(+), 31 deletions(-) diff --git a/public/s/js/admin.js b/public/s/js/admin.js index d0c14f2..dba61bb 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -401,6 +401,49 @@ }, { hideReason: true, confirmText: 'Set Password', unsafeContent: true }); }; + window.adminRenameUser = async (btn) => { + const id = btn.dataset.id; + const currentName = btn.dataset.name; + const currentUsername = btn.dataset.username; + + if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded'); + + ModAction.confirm( + 'Rename User', + 'Enter a new login name for ' + escHTML(currentName) + '.
' + + 'Current login: ' + escHTML(currentUsername) + ' — All uploads will be reassigned. User sessions will be invalidated. And the user has to login with the NEW name from now on.', + async (newUsername) => { + const data = await post('/api/v2/admin/users/rename', { user_id: id, new_username: newUsername }); + if (data.success) { + showFlash(data.msg, 'success'); + // Update the row in-place: links, text, and all button data attributes + const row = document.getElementById('user-row-' + id); + if (row) { + // Update the name link + const link = row.querySelector('.user-info-cell a'); + if (link) { + link.href = '/user/' + data.new_login; + // Only overwrite text if there's no display_name (plain username link) + if (!link.querySelector('span[style*="accent"]')) { + link.textContent = data.new_user; + } + } + // Update all buttons in the row with the new name/username + row.querySelectorAll('[data-username]').forEach(el => { el.dataset.username = data.new_login; }); + row.querySelectorAll('[data-name]').forEach(el => { el.dataset.name = data.new_user; }); + // Update activity stat links + row.querySelectorAll('a[href^="/user/"]').forEach(a => { + a.href = a.href.replace(/\/user\/[^/]+/, '/user/' + data.new_login); + }); + } + } else { + throw new Error(data.msg || 'Rename failed'); + } + }, + { hideReason: false, singleLine: true, confirmText: 'Rename', placeholder: 'new username' } + ); + }; + window.adminDeleteUser = async (btn) => { const id = btn.dataset.id; const name = btn.dataset.name; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index a25c12a..d501a53 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -7249,16 +7249,15 @@ class ModAction { if (!modal) return window.flashMessage('Error: Mod modal not found', 3000, 'error'); const titleEl = document.getElementById('mod-action-title'); const contentEl = document.getElementById('mod-action-content'); - const textareaEl = document.getElementById('mod-reason'); - const inputEl = document.getElementById('mod-reason-input'); + const reasonEl = document.getElementById('mod-reason'); const confirmBtn = document.getElementById('mod-action-confirm'); const cancelBtn = document.getElementById('mod-action-cancel'); const errorEl = document.getElementById('mod-action-error'); - // Pick the active input element based on singleLine option const singleLine = options.singleLine || false; - const reasonEl = singleLine ? inputEl : textareaEl; - const inactiveEl = singleLine ? textareaEl : inputEl; + const hideReason = options.hideReason || false; + const allowEmpty = options.allowEmpty || false; + const i18n = window.f0ckI18n || {}; titleEl.innerText = title; if (options.unsafeContent) { @@ -7266,15 +7265,23 @@ class ModAction { } else { contentEl.innerHTML = Sanitizer.clean(promptHtml); } + reasonEl.value = ''; - inactiveEl.value = ''; - inactiveEl.style.display = 'none'; errorEl.style.display = 'none'; modal.style.display = 'flex'; - const hideReason = options.hideReason || false; - const allowEmpty = options.allowEmpty || false; - const i18n = window.f0ckI18n || {}; + // Apply single-line mode: style the textarea to look and act like a text input + if (singleLine) { + reasonEl.rows = 1; + reasonEl.style.resize = 'none'; + reasonEl.style.overflow = 'hidden'; + reasonEl.style.height = 'auto'; + } else { + reasonEl.rows = 3; + reasonEl.style.resize = ''; + reasonEl.style.overflow = ''; + reasonEl.style.height = ''; + } if (hideReason) { reasonEl.style.display = 'none'; @@ -7315,24 +7322,19 @@ class ModAction { } }; + // Block Enter from inserting newlines in single-line mode; instead submit + const enterHandler = singleLine ? (e) => { + if (e.key === 'Enter') { e.preventDefault(); onConfirm(); } + } : null; + if (enterHandler) reasonEl.addEventListener('keydown', enterHandler); + const cleanup = () => { confirmBtn.onclick = null; cancelBtn.onclick = null; - if (singleLine && inputEl._enterHandler) { - inputEl.removeEventListener('keydown', inputEl._enterHandler); - inputEl._enterHandler = null; - } + if (enterHandler) reasonEl.removeEventListener('keydown', enterHandler); confirmBtn.disabled = false; }; - // For single-line input: submit on Enter - if (singleLine && !hideReason) { - inputEl._enterHandler = (e) => { - if (e.key === 'Enter') { e.preventDefault(); onConfirm(); } - }; - inputEl.addEventListener('keydown', inputEl._enterHandler); - } - confirmBtn.onclick = onConfirm; cancelBtn.onclick = close; diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 927b0c0..b19e725 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -1272,6 +1272,71 @@ export default (router, tpl) => { }); + router.post(/^\/api\/v2\/admin\/users\/rename\/?$/, lib.auth, async (req, res) => { + try { + const { user_id, new_username } = req.post; + if (!user_id) throw new Error('Missing user_id'); + if (!new_username || !new_username.trim()) throw new Error('Missing new_username'); + + const newName = new_username.trim(); + + // Validate format (same rules as registration) + if (!/^[a-zA-Z0-9._-]+$/.test(newName)) { + throw new Error('Invalid username. Only A-Z, 0-9, _, -, and . are allowed.'); + } + if (newName.length < 2 || newName.length > 32) { + throw new Error('Username must be between 2 and 32 characters.'); + } + + // Get current user info + const target = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`; + if (!target.length) throw new Error('User not found'); + if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be renamed.'); + + const oldLogin = target[0].login; + const oldUser = target[0].user; + const newLogin = newName.toLowerCase(); + + if (newLogin === oldLogin && newName === oldUser) throw new Error('New username is the same as the current one.'); + + // Check for conflicts + const conflict = await db`SELECT id FROM "user" WHERE (lower(login) = ${newLogin} OR lower("user") = lower(${newName})) AND id != ${+user_id} LIMIT 1`; + if (conflict.length) throw new Error(`Username "${newName}" is already taken.`); + + await db.begin(async sql => { + // 1. Update the user record + await sql`UPDATE "user" SET login = ${newLogin}, "user" = ${newName} WHERE id = ${+user_id}`; + + // 2. Update items.username (matches both old login and old display name) + await sql`UPDATE items SET username = ${newLogin} WHERE username ILIKE ${oldLogin} OR username ILIKE ${oldUser}`; + + // 3. Clear old login_attempts so the new name starts clean + await sql`DELETE FROM login_attempts WHERE username = ${oldLogin}`; + }); + + // Invalidate all sessions so the user must re-log with the new name + await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`; + + // Log it in audit + await audit.log(req.session.id, 'admin_rename_user', 'user', +user_id, { + old_login: oldLogin, + new_login: newLogin, + old_user: oldUser, + new_user: newName + }); + + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: true, + new_login: newLogin, + new_user: newName, + msg: `User renamed from "${oldLogin}" to "${newLogin}". All uploads updated. Sessions invalidated.` + })); + } catch (err) { + console.error('[ADMIN] Rename failed:', err); + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); + } + }); + // About page text editor router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => { const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`; diff --git a/views/admin/users.html b/views/admin/users.html index fdf9a6e..ac6bcab 100644 --- a/views/admin/users.html +++ b/views/admin/users.html @@ -128,10 +128,10 @@ - + - - + + @@ -283,7 +283,7 @@ var hint = currentDisplay ? 'Current nick: ' + escHTML(currentDisplay) + '
Enter a new stylized name, or leave empty to clear it.' - : 'Enter a stylized display name for ' + escHTML(userName) + ' (e.g. F.O.O). Leave empty to clear.'; + : 'Enter a stylized display name for ' + escHTML(userName) + '. Leave empty to clear.'; ModAction.confirm('Set Display Name', hint, async (newName) => { var res = await fetch('/api/v2/admin/users/set-display-name', { @@ -310,7 +310,7 @@ } else { throw new Error(data.msg || 'Failed to set display name'); } - }, { hideReason: false, allowEmpty: true, confirmText: 'Set Nick', placeholder: currentDisplay || 'e.g. F.O.O' }); + }, { hideReason: false, singleLine: true, allowEmpty: true, confirmText: 'Set Nick', placeholder: currentDisplay || 'Set nickname' }); } async function adminLockLayout(btn) { diff --git a/views/admin/users_list.html b/views/admin/users_list.html index 3f05c60..6e8ca88 100644 --- a/views/admin/users_list.html +++ b/views/admin/users_list.html @@ -16,17 +16,17 @@
User & ContactUser ActivityRegistrationAccount AgeDateAge Status Actions
-
{{ new Date(u.created_at).toLocaleDateString() }}
+
{{ new Date(u.created_at).toLocaleDateString() }}
{{ Math.floor(u.age_days) }} Days
@@ -85,6 +85,7 @@ @if(u.id && u.login !== 'deleted_user') + diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 2101d04..d1a0951 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -4,7 +4,6 @@

{{ t('mod.confirm_action') }}

-