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.