update usermanager

This commit is contained in:
2026-05-24 18:31:35 +02:00
parent 3abefe64de
commit d8a8626dae
6 changed files with 141 additions and 31 deletions

View File

@@ -401,6 +401,49 @@
}, { hideReason: true, confirmText: 'Set Password', unsafeContent: true }); }, { 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 <strong>' + escHTML(currentName) + '</strong>.<br>' +
'<small style="color:#888;">Current login: <code>' + escHTML(currentUsername) + '</code> — All uploads will be reassigned. User sessions will be invalidated. And the user has to login with the NEW name from now on.</small>',
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) => { window.adminDeleteUser = async (btn) => {
const id = btn.dataset.id; const id = btn.dataset.id;
const name = btn.dataset.name; const name = btn.dataset.name;

View File

@@ -7249,16 +7249,15 @@ class ModAction {
if (!modal) return window.flashMessage('Error: Mod modal not found', 3000, 'error'); if (!modal) return window.flashMessage('Error: Mod modal not found', 3000, 'error');
const titleEl = document.getElementById('mod-action-title'); const titleEl = document.getElementById('mod-action-title');
const contentEl = document.getElementById('mod-action-content'); const contentEl = document.getElementById('mod-action-content');
const textareaEl = document.getElementById('mod-reason'); const reasonEl = document.getElementById('mod-reason');
const inputEl = document.getElementById('mod-reason-input');
const confirmBtn = document.getElementById('mod-action-confirm'); const confirmBtn = document.getElementById('mod-action-confirm');
const cancelBtn = document.getElementById('mod-action-cancel'); const cancelBtn = document.getElementById('mod-action-cancel');
const errorEl = document.getElementById('mod-action-error'); const errorEl = document.getElementById('mod-action-error');
// Pick the active input element based on singleLine option
const singleLine = options.singleLine || false; const singleLine = options.singleLine || false;
const reasonEl = singleLine ? inputEl : textareaEl; const hideReason = options.hideReason || false;
const inactiveEl = singleLine ? textareaEl : inputEl; const allowEmpty = options.allowEmpty || false;
const i18n = window.f0ckI18n || {};
titleEl.innerText = title; titleEl.innerText = title;
if (options.unsafeContent) { if (options.unsafeContent) {
@@ -7266,15 +7265,23 @@ class ModAction {
} else { } else {
contentEl.innerHTML = Sanitizer.clean(promptHtml); contentEl.innerHTML = Sanitizer.clean(promptHtml);
} }
reasonEl.value = ''; reasonEl.value = '';
inactiveEl.value = '';
inactiveEl.style.display = 'none';
errorEl.style.display = 'none'; errorEl.style.display = 'none';
modal.style.display = 'flex'; modal.style.display = 'flex';
const hideReason = options.hideReason || false; // Apply single-line mode: style the textarea to look and act like a text input
const allowEmpty = options.allowEmpty || false; if (singleLine) {
const i18n = window.f0ckI18n || {}; 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) { if (hideReason) {
reasonEl.style.display = 'none'; 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 = () => { const cleanup = () => {
confirmBtn.onclick = null; confirmBtn.onclick = null;
cancelBtn.onclick = null; cancelBtn.onclick = null;
if (singleLine && inputEl._enterHandler) { if (enterHandler) reasonEl.removeEventListener('keydown', enterHandler);
inputEl.removeEventListener('keydown', inputEl._enterHandler);
inputEl._enterHandler = null;
}
confirmBtn.disabled = false; 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; confirmBtn.onclick = onConfirm;
cancelBtn.onclick = close; cancelBtn.onclick = close;

View File

@@ -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 // About page text editor
router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => {
const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`; const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`;

View File

@@ -128,10 +128,10 @@
<table class="admin-users-table responsive-table"> <table class="admin-users-table responsive-table">
<thead> <thead>
<tr> <tr>
<th>User & Contact</th> <th>User</th>
<th>Activity</th> <th>Activity</th>
<th>Registration</th> <th>Date</th>
<th>Account Age</th> <th>Age</th>
<th>Status</th> <th>Status</th>
<th style="text-align: right;">Actions</th> <th style="text-align: right;">Actions</th>
</tr> </tr>
@@ -283,7 +283,7 @@
var hint = currentDisplay var hint = currentDisplay
? 'Current nick: <strong style="color: var(--accent);">' + escHTML(currentDisplay) + '</strong><br>Enter a new stylized name, or leave empty to clear it.' ? 'Current nick: <strong style="color: var(--accent);">' + escHTML(currentDisplay) + '</strong><br>Enter a new stylized name, or leave empty to clear it.'
: 'Enter a stylized display name for <strong>' + escHTML(userName) + '</strong> (e.g. <code>F.O.O</code>). Leave empty to clear.'; : 'Enter a stylized display name for <strong>' + escHTML(userName) + '</strong>. Leave empty to clear.';
ModAction.confirm('Set Display Name', hint, async (newName) => { ModAction.confirm('Set Display Name', hint, async (newName) => {
var res = await fetch('/api/v2/admin/users/set-display-name', { var res = await fetch('/api/v2/admin/users/set-display-name', {
@@ -310,7 +310,7 @@
} else { } else {
throw new Error(data.msg || 'Failed to set display name'); 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) { async function adminLockLayout(btn) {

View File

@@ -16,17 +16,17 @@
<td data-label="Activity"> <td data-label="Activity">
<div style="display: flex; align-items: center; gap: 15px; white-space: nowrap;"> <div style="display: flex; align-items: center; gap: 15px; white-space: nowrap;">
<a href="/user/{{ u.login }}" target="_blank" class="stat-box" title="Uploads" style="text-decoration: none;"> <a href="/user/{{ u.login }}" target="_blank" class="stat-box" title="Uploads" style="text-decoration: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg> <i class="fa fa-upload" style="opacity: 0.6; font-size: 13px;"></i>
<strong>{{ u.upload_count }}</strong> <strong>{{ u.upload_count }}</strong>
</a> </a>
<a href="/user/{{ u.login }}/comments" target="_blank" class="stat-box" title="Comments" style="text-decoration: none;"> <a href="/user/{{ u.login }}/comments" target="_blank" class="stat-box" title="Comments" style="text-decoration: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg> <i class="fa fa-comment" style="opacity: 0.6; font-size: 13px;"></i>
<strong>{{ u.comment_count }}</strong> <strong>{{ u.comment_count }}</strong>
</a> </a>
</div> </div>
</td> </td>
<td data-label="Registration"> <td data-label="Registration">
<div style="font-size: 0.85rem; color: #eee; font-weight: 600; cursor: help;" tooltip="Method: {{ u.reg_method }}">{{ new Date(u.created_at).toLocaleDateString() }}</div> <div style="font-size: 0.85rem; color: #eee; font-weight: 600; cursor: help;" tooltip="Method: {{ u.reg_method === 'Legacy' ? 'Legacy Account' : (u.reg_method ? 'Invite Token: ' + u.reg_method.slice(0, 8) + '…' : (u.activated ? 'Open Registration' : 'Email Verification')) }}">{{ new Date(u.created_at).toLocaleDateString() }}</div>
</td> </td>
<td data-label="Account Age"> <td data-label="Account Age">
<div style="font-size: 0.85rem; font-weight: 600; color: #aaa;">{{ Math.floor(u.age_days) }} Days</div> <div style="font-size: 0.85rem; font-weight: 600; color: #aaa;">{{ Math.floor(u.age_days) }} Days</div>
@@ -85,6 +85,7 @@
@if(u.id && u.login !== 'deleted_user') @if(u.id && u.login !== 'deleted_user')
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-display="{!! u.display_name || '' !!}" onclick="adminSetDisplayName(this)" class="btn-modern btn-nick" style="background: rgba(100, 200, 100, 0.1); color: #64c864; border: 1px solid rgba(100, 200, 100, 0.2);" title="Set stylized display name">✏ Nick</button> <button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-display="{!! u.display_name || '' !!}" onclick="adminSetDisplayName(this)" class="btn-modern btn-nick" style="background: rgba(100, 200, 100, 0.1); color: #64c864; border: 1px solid rgba(100, 200, 100, 0.2);" title="Set stylized display name">✏ Nick</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminRenameUser(this)" class="btn-modern" style="background: rgba(255, 165, 0, 0.1); color: #ffa500; border: 1px solid rgba(255, 165, 0, 0.2);" title="Rename username (updates all uploads)"><i class="fa fa-at"></i> Rename</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminSetPassword(this)" class="btn-modern btn-pw" style="background: rgba(var(--accent-rgb, 0, 150, 255), 0.1); color: var(--accent, #0096ff); border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);">Set PW</button> <button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminSetPassword(this)" class="btn-modern btn-pw" style="background: rgba(var(--accent-rgb, 0, 150, 255), 0.1); color: var(--accent, #0096ff); border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);">Set PW</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-locked="{{ u.force_comment_display_mode }}" data-mode="{{ u.comment_display_mode }}" onclick="adminLockLayout(this)" class="btn-modern" style="background: rgba(255, 100, 0, 0.1); color: #ff6400; border: 1px solid rgba(255, 100, 0, 0.2);" title="{{ u.force_comment_display_mode ? 'Unlock Layout' : 'Lock Layout' }}"><i class="fa fa-{{ u.force_comment_display_mode ? 'lock-open' : 'lock' }}"></i> {{ u.force_comment_display_mode ? 'Unlock' : 'Lock' }}</button> <button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-locked="{{ u.force_comment_display_mode }}" data-mode="{{ u.comment_display_mode }}" onclick="adminLockLayout(this)" class="btn-modern" style="background: rgba(255, 100, 0, 0.1); color: #ff6400; border: 1px solid rgba(255, 100, 0, 0.2);" title="{{ u.force_comment_display_mode ? 'Unlock Layout' : 'Lock Layout' }}"><i class="fa fa-{{ u.force_comment_display_mode ? 'lock-open' : 'lock' }}"></i> {{ u.force_comment_display_mode ? 'Unlock' : 'Lock' }}</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminDeleteUser(this)" class="btn-modern btn-delete" style="background: rgba(217, 83, 79, 0.1); color: #d9534f; border: 1px solid rgba(217, 83, 79, 0.2);">Delete</button> <button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminDeleteUser(this)" class="btn-modern btn-delete" style="background: rgba(217, 83, 79, 0.1); color: #d9534f; border: 1px solid rgba(217, 83, 79, 0.2);">Delete</button>

View File

@@ -4,7 +4,6 @@
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3> <h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
<div id="mod-action-content"></div> <div id="mod-action-content"></div>
<textarea id="mod-reason" class="mod-reason" placeholder="{{ t('mod.reason_placeholder') }}"></textarea> <textarea id="mod-reason" class="mod-reason" placeholder="{{ t('mod.reason_placeholder') }}"></textarea>
<input type="text" id="mod-reason-input" class="mod-reason" placeholder="" style="display:none; resize:none;">
<div id="mod-action-error" class="error-msg"></div> <div id="mod-action-error" class="error-msg"></div>
<div class="modal-actions"> <div class="modal-actions">
<button id="mod-action-confirm" class="btn-danger">{{ t('mod.confirm') }}</button> <button id="mod-action-confirm" class="btn-danger">{{ t('mod.confirm') }}</button>