fd
This commit is contained in:
@@ -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 <strong>' + escHTML(name) + '</strong>. Must be at least 20 characters.<br><br>' +
|
||||
'<input type="password" id="admin-pw-new" class="input" placeholder="New password (min 20 chars)" style="width:100%;margin-bottom:8px;" autocomplete="new-password">' +
|
||||
'<input type="password" id="admin-pw-confirm" class="input" placeholder="Confirm new password" style="width:100%;" autocomplete="new-password">';
|
||||
|
||||
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');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
throw new Error(data.msg || 'Failed to set password');
|
||||
}
|
||||
}, { 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 {
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm(
|
||||
'Delete User',
|
||||
'<strong style="color:#d9534f">CRITICAL ACTION</strong>: Permanently delete user <strong>' + escHTML(name) + '</strong>?<br><br>All their uploads and comments will be reassigned to <code>deleted_user</code>. <strong>This cannot be undone.</strong>',
|
||||
async () => {
|
||||
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
showFlash(data.msg, 'success');
|
||||
document.getElementById(`user-row-${id}`)?.remove();
|
||||
} else {
|
||||
alert(data.msg || 'Failed to delete user');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
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 {
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm(
|
||||
'Delete All Halls',
|
||||
'Permanently delete <strong>ALL halls</strong> for <strong>' + escHTML(name) + '</strong>? <strong>This cannot be undone.</strong>',
|
||||
async () => {
|
||||
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
showFlash(data.msg, 'success');
|
||||
} else {
|
||||
alert(data.msg || 'Failed to delete halls');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
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' }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
|
||||
|
||||
@@ -803,7 +803,10 @@ export default (router, tpl) => {
|
||||
|
||||
// User Management Routes
|
||||
router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => {
|
||||
const q = req.url.qs?.q || '';
|
||||
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);
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> registered members.</p>
|
||||
</div>
|
||||
<div style="flex-grow: 1; max-width: 400px; position: relative;">
|
||||
<input type="text" id="user-search" placeholder="Search by name or email..."
|
||||
<input type="text" id="user-search" placeholder='Search by name or email… use "exact" for exact match'
|
||||
style="width: 100%; padding: 12px 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s;"
|
||||
value="{{ q }}">
|
||||
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; display: none;">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
|
||||
<div id="mod-action-content"></div>
|
||||
<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 class="modal-actions">
|
||||
<button id="mod-action-confirm" class="btn-danger">{{ t('mod.confirm') }}</button>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@if(custom_brand_image)
|
||||
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: initial;">
|
||||
@else
|
||||
<span class="f0ck">{{ domain }}</span>
|
||||
<span class="f0ck">{{ domain.split('.')[0] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
@if(custom_brand_image)
|
||||
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: auto;">
|
||||
@else
|
||||
<span class="f0ck">{{ domain }}</span>
|
||||
<span class="f0ck">{{ domain.split('.')[0] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user