diff --git a/public/s/js/admin.js b/public/s/js/admin.js index 5f32731..d0c14f2 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -379,40 +379,48 @@ window.adminSetPassword = async (btn) => { const id = btn.dataset.id; const name = btn.dataset.name; - const password = prompt(`Enter new password for ${name} (min 20 chars):`); - if (!password) return; - if (password.length < 20) return alert('Password must be at least 20 characters.'); - if (!confirm(`Are you sure you want to set a new password for ${name}? This will invalidate all their existing sessions and force them to change it on next login.`)) return; + if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded'); - try { + const hint = + 'Set a new password for ' + escHTML(name) + '. Must be at least 20 characters.

' + + '' + + ''; + + ModAction.confirm('Set Password', hint, async () => { + const password = document.getElementById('admin-pw-new')?.value || ''; + const confirm = document.getElementById('admin-pw-confirm')?.value || ''; + if (password.length < 20) throw new Error('Password must be at least 20 characters.'); + if (password !== confirm) throw new Error('Passwords do not match.'); const data = await post('/api/v2/admin/users/set-password', { user_id: id, password }); if (data.success) { - alert(data.msg); + showFlash(data.msg, 'success'); } else { - alert(data.msg || 'Failed to set password'); + throw new Error(data.msg || 'Failed to set password'); } - } catch (err) { - alert('Network error'); - } + }, { hideReason: true, confirmText: 'Set Password', unsafeContent: true }); }; window.adminDeleteUser = async (btn) => { const id = btn.dataset.id; const name = btn.dataset.name; - if (!confirm(`CRITICAL ACTION: Are you sure you want to PERMANENTLY DELETE user ${name}? All their uploads and comments will be reassigned to 'deleted_user'. This cannot be undone.`)) return; - try { - const data = await post('/api/v2/admin/users/delete', { user_id: id }); - if (data.success) { - alert(data.msg); - document.getElementById(`user-row-${id}`)?.remove(); - } else { - alert(data.msg || 'Failed to delete user'); - } - } catch (err) { - alert('Network error'); - } + if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded'); + + ModAction.confirm( + 'Delete User', + 'CRITICAL ACTION: Permanently delete user ' + escHTML(name) + '?

All their uploads and comments will be reassigned to deleted_user. This cannot be undone.', + async () => { + const data = await post('/api/v2/admin/users/delete', { user_id: id }); + if (data.success) { + showFlash(data.msg, 'success'); + document.getElementById(`user-row-${id}`)?.remove(); + } else { + throw new Error(data.msg || 'Failed to delete user'); + } + }, + { hideReason: true, confirmText: 'Delete User' } + ); }; window.adminResetLoginAttempts = async (btn) => { @@ -422,8 +430,13 @@ try { const data = await post('/api/v2/admin/users/reset-login-attempts', { username }); if (data.success) { - alert(data.msg); - window.location.reload(); // Quickest way to refresh badges + showFlash(data.msg, 'success'); + // Remove the failed attempt badges and reset button from the row in-place + const row = btn.closest('tr'); + if (row) { + row.querySelectorAll('.status-badge[style*="ffcc00"], .status-badge[style*="ff4d4d"]').forEach(el => el.remove()); + } + btn.remove(); } else { alert(data.msg || 'Failed to reset attempts'); } @@ -435,18 +448,22 @@ window.adminBulkDeleteHalls = async (btn) => { const id = btn.dataset.id; const name = btn.dataset.name; - if (!confirm(`Are you sure you want to PERMANENTLY DELETE ALL HALLS for ${name}? This cannot be undone.`)) return; - try { - const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id }); - if (data.success) { - alert(data.msg); - } else { - alert(data.msg || 'Failed to delete halls'); - } - } catch (err) { - alert('Network error'); - } + if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded'); + + ModAction.confirm( + 'Delete All Halls', + 'Permanently delete ALL halls for ' + escHTML(name) + '? This cannot be undone.', + async () => { + const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id }); + if (data.success) { + showFlash(data.msg, 'success'); + } else { + throw new Error(data.msg || 'Failed to delete halls'); + } + }, + { hideReason: true, confirmText: 'Delete Everything' } + ); }; window.adminReassignUploads = async (btn) => { @@ -473,7 +490,7 @@ throw new Error(res.msg || 'Reassignment failed'); } }, - { hideReason: false, confirmText: 'Reassign', placeholder: 'target username' } + { hideReason: false, singleLine: true, confirmText: 'Reassign', placeholder: 'target username' } ); }; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 84b9f06..a25c12a 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -7249,24 +7249,37 @@ 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 reasonEl = document.getElementById('mod-reason'); + const textareaEl = document.getElementById('mod-reason'); + const inputEl = document.getElementById('mod-reason-input'); 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; + titleEl.innerText = title; - contentEl.innerHTML = Sanitizer.clean(promptHtml); + if (options.unsafeContent) { + contentEl.innerHTML = promptHtml; // Trusted admin-only content — skip sanitizer + } else { + contentEl.innerHTML = Sanitizer.clean(promptHtml); + } reasonEl.value = ''; - if (options.placeholder) reasonEl.placeholder = options.placeholder; - else reasonEl.placeholder = ''; + 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 || {}; - reasonEl.style.display = hideReason ? 'none' : 'block'; - if (!hideReason) { + + if (hideReason) { + reasonEl.style.display = 'none'; + } else { + reasonEl.style.display = 'block'; reasonEl.placeholder = options.placeholder || (allowEmpty ? (i18n.reason_optional || 'Reason (optional)') : (i18n.reason_required_label || 'Reason (required)')); reasonEl.focus(); @@ -7305,9 +7318,21 @@ class ModAction { const cleanup = () => { confirmBtn.onclick = null; cancelBtn.onclick = null; + if (singleLine && inputEl._enterHandler) { + inputEl.removeEventListener('keydown', inputEl._enterHandler); + inputEl._enterHandler = null; + } 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 18ed2e4..927b0c0 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -803,8 +803,11 @@ export default (router, tpl) => { // User Management Routes router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => { - const q = req.url.qs?.q || ''; -const page = Math.max(1, parseInt(req.url.qs?.page) || 1); + const rawQ = req.url.qs?.q || ''; + // Exact match mode: strip surrounding double quotes and match exactly + const exactMatch = rawQ.startsWith('"') && rawQ.endsWith('"') && rawQ.length > 2; + const q = exactMatch ? rawQ.slice(1, -1) : rawQ; + const page = Math.max(1, parseInt(req.url.qs?.page) || 1); const limit = 50; const offset = (page - 1) * limit; @@ -816,7 +819,10 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1); (SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method FROM "user" u LEFT JOIN user_options uo ON uo.user_id = u.id - ${q ? db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` : db``} + ${q ? (exactMatch + ? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})` + : db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` + ) : db``} ), ghost_users AS ( SELECT @@ -825,7 +831,10 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1); NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method FROM items i WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) - ${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``} + ${q ? (exactMatch + ? db`AND lower(i.username) = lower(${q})` + : db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` + ) : db``} GROUP BY i.username ), all_users AS ( @@ -867,13 +876,19 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1); const totalCountActual = await db` SELECT COUNT(*) as c FROM "user" u - ${q ? db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` : db``} + ${q ? (exactMatch + ? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})` + : db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` + ) : db``} `; const totalCountGhost = await db` SELECT COUNT(DISTINCT i.username) as c FROM items i WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username) - ${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``} + ${q ? (exactMatch + ? db`AND lower(i.username) = lower(${q})` + : db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` + ) : db``} `; const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c); diff --git a/views/admin/users.html b/views/admin/users.html index cc0abea..fdf9a6e 100644 --- a/views/admin/users.html +++ b/views/admin/users.html @@ -113,7 +113,7 @@

Administration hub for {!! total !!} registered members.

-