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 @@
| User & Contact | +User | Activity | -Registration | -Account Age | +Date | +Age | 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') }}- |
|---|