From b96f22be8a769c254331eff21ace10853566d7e4 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sun, 12 Jul 2026 19:15:37 +0200 Subject: [PATCH] add option to create users from admin dashboard and give or take permissions via dashboard --- public/s/css/f0ckm.css | 7 + src/inc/routes/admin.mjs | 111 +++++++++++++++ views/admin/users.html | 261 ++++++++++++++++++++++++++++++++++-- views/admin/users_list.html | 1 + 4 files changed, 372 insertions(+), 8 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 274e196..c2362d2 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -54,6 +54,7 @@ html[theme='f0ck'] { /* appearance */ background: black; --comment-bg: black; + --motd-bg: var(--gray); } html[theme="f0ck"] .admin-search button { @@ -15062,6 +15063,12 @@ body.scroller-active #gchat-reopen-bubble { box-sizing: border-box; } +/* Anchor targets: offset scroll so fixed navbar + quicknav don't overlap headings */ +.settings h2[id] { + /* navbar (~50px) + quicknav bar (~37px) + a little breathing room */ + scroll-margin-top: calc(var(--navbar-h, 50px) + 44px); +} + /* Settings quick-nav bar */ #settings-quicknav { display: flex; diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 2d3371f..27c4eb3 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -968,11 +968,57 @@ export default (router, tpl) => { } }); + router.post(/^\/api\/v2\/admin\/users\/set-role\/?$/, lib.auth, async (req, res) => { + try { + const { user_id, role } = req.post; + if (!user_id) throw new Error('Missing user_id'); + if (!['user', 'mod', 'admin'].includes(role)) throw new Error('Invalid role. Must be user, mod, or admin.'); + + // Fetch target + const target = await db`SELECT id, login 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.'); + + // Prevent self-demotion + if (+user_id === req.session.id && role !== 'admin') { + throw new Error('You cannot change your own role away from admin.'); + } + + const isAdmin = role === 'admin'; + const isMod = role === 'mod'; + + await db` + UPDATE "user" + SET admin = ${isAdmin}, is_moderator = ${isMod} + WHERE id = ${+user_id} + `; + + // Invalidate sessions so permissions refresh on next login + await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`; + + await audit.log(req.session.id, 'admin_set_role', 'user', +user_id, { + target_login: target[0].login, + role + }); + + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: true, + role, + msg: `Role for "${target[0].login}" set to ${role}.` + })); + } catch (err) { + console.error('[ADMIN] Set role failed:', err); + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message })); + } + }); + + router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => { try { const { user_id, mode, lock } = req.post; if (!user_id) throw new Error('Missing user_id'); + const isLocked = lock === true || lock === 'true' || lock === 1; const targetMode = parseInt(mode, 10); @@ -1338,6 +1384,71 @@ export default (router, tpl) => { } }); + router.post(/^\/api\/v2\/admin\/users\/create\/?$/, lib.auth, async (req, res) => { + try { + let { username, email, password, role } = req.post; + if (!username || !username.trim()) throw new Error('Username is required.'); + username = username.trim(); + + if (!/^[a-zA-Z0-9._-]+$/.test(username)) { + throw new Error('Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.'); + } + if (username.length < 2 || username.length > 32) { + throw new Error('Username must be between 2 and 32 characters.'); + } + if (!password || password.length < 20) { + throw new Error('Password must be at least 20 characters long.'); + } + + // Check for existing user + const existing = await db` + SELECT id FROM "user" + WHERE lower(login) = lower(${username}) OR lower("user") = lower(${username}) + ${email ? db`OR (email IS NOT NULL AND lower(email) = lower(${email}))` : db``} + LIMIT 1 + `; + if (existing.length) throw new Error('Username or email is already taken.'); + + const isAdmin = role === 'admin'; + const isMod = role === 'mod'; + const hash = await lib.hash(password); + const ts = ~~(Date.now() / 1e3); + + const { getDefaultLayout } = await import('../settings.mjs'); + + const newUser = await db` + INSERT INTO "user" (login, password, "user", created_at, admin, is_moderator, email, activated, activation_token) + VALUES ( + ${username.toLowerCase()}, ${hash}, ${username}, + to_timestamp(${ts}), ${isAdmin}, ${isMod}, + ${email || null}, true, NULL + ) + RETURNING id, login + `; + const userId = newUser[0].id; + + await db` + INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping) + VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}) + `; + + await audit.log(req.session.id, 'admin_create_user', 'user', userId, { + new_login: username.toLowerCase(), + role: role || 'user' + }); + + return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ + success: true, + user_id: userId, + username: username.toLowerCase(), + msg: `User "${username.toLowerCase()}" created successfully.` + })); + } catch (err) { + console.error('[ADMIN] Create user 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 c34137a..6a084d5 100644 --- a/views/admin/users.html +++ b/views/admin/users.html @@ -83,6 +83,97 @@ .btn-files { background: #f08c00; } .btn-comms { background: #4dabf7; } .btn-verify { background: #5c7cfa; } + .btn-create { background: linear-gradient(135deg, #5c7cfa, #339af0); } + .btn-role { background: rgba(180, 100, 255, 0.15); color: #c084fc; border: 1px solid rgba(180, 100, 255, 0.3); } + .btn-role:hover { background: rgba(180, 100, 255, 0.25); opacity: 1; } + + /* Create User Modal */ + #create-user-modal { + display: none; + position: fixed; + inset: 0; + z-index: 9999; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + } + #create-user-modal.open { display: flex; } + .cum-box { + background: #141414; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + padding: 30px; + width: 100%; + max-width: 460px; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); + position: relative; + animation: cum-in 0.2s ease; + } + @keyframes cum-in { + from { transform: translateY(-16px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + .cum-box h3 { + margin: 0 0 20px; + font-size: 1.1rem; + font-weight: 700; + color: var(--white); + } + .cum-field { + margin-bottom: 14px; + } + .cum-field label { + display: block; + font-size: 0.75rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 5px; + } + .cum-field input, + .cum-field select { + width: 100%; + padding: 10px 14px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + color: #fff; + font-size: 0.9rem; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; + } + .cum-field input:focus, + .cum-field select:focus { + border-color: var(--accent); + } + .cum-field select option { background: #1a1a1a; } + .cum-actions { + display: flex; + gap: 10px; + margin-top: 20px; + justify-content: flex-end; + } + .cum-close-btn { + position: absolute; + top: 16px; + right: 16px; + background: none; + border: none; + color: #666; + font-size: 1.2rem; + cursor: pointer; + line-height: 1; + transition: color 0.2s; + } + .cum-close-btn:hover { color: #fff; } + #cum-error { + font-size: 0.8rem; + color: #ff6b6b; + margin-top: 12px; + display: none; + } .user-avatar { width: 40px; @@ -106,21 +197,64 @@ } + +
+
+ +

Create New User

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+

User Management

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

-
- - @@ -433,6 +567,117 @@ var style = document.createElement('style'); style.innerHTML = '@keyframes rotate { 100% { transform: rotate(360deg); } }'; document.head.appendChild(style); + + // --- Create User Modal --- + var createUserModal = document.getElementById('create-user-modal'); + + function openCreateUserModal() { + document.getElementById('cum-username').value = ''; + document.getElementById('cum-email').value = ''; + document.getElementById('cum-password').value = ''; + document.getElementById('cum-role').value = 'user'; + document.getElementById('cum-error').style.display = 'none'; + document.getElementById('cum-error').textContent = ''; + createUserModal.classList.add('open'); + setTimeout(function() { document.getElementById('cum-username').focus(); }, 80); + } + + function closeCreateUserModal() { + createUserModal.classList.remove('open'); + } + + // Close on backdrop click + createUserModal.addEventListener('click', function(e) { + if (e.target === createUserModal) closeCreateUserModal(); + }); + + // Close on Escape + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && createUserModal.classList.contains('open')) closeCreateUserModal(); + }); + + async function submitCreateUser() { + var btn = document.getElementById('cum-submit-btn'); + var errEl = document.getElementById('cum-error'); + var username = document.getElementById('cum-username').value.trim(); + var email = document.getElementById('cum-email').value.trim(); + var password = document.getElementById('cum-password').value; + var role = document.getElementById('cum-role').value; + + errEl.style.display = 'none'; + errEl.textContent = ''; + + if (!username) { errEl.textContent = 'Username is required.'; errEl.style.display = 'block'; return; } + if (!password) { errEl.textContent = 'Password is required.'; errEl.style.display = 'block'; return; } + + btn.disabled = true; + btn.innerHTML = ' Creating…'; + + try { + var res = await fetch('/api/v2/admin/users/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email: email || null, password, role }) + }); + var data = await res.json(); + + if (data.success) { + closeCreateUserModal(); + showFlash(data.msg, 'success'); + // Refresh the table from page 1 + currentPage = 1; + fetchUsers(1, searchQuery, false); + // Update counter + var cnt = parseInt(countSpan.textContent) || 0; + countSpan.textContent = cnt + 1; + } else { + errEl.textContent = data.msg || 'Failed to create user.'; + errEl.style.display = 'block'; + } + } catch (e) { + errEl.textContent = 'Network error: ' + e.message; + errEl.style.display = 'block'; + } finally { + btn.disabled = false; + btn.innerHTML = ' Create User'; + } + } + async function adminSetRole(btn) { + var id = btn.dataset.id; + var userName = btn.dataset.name; + var currentRole = btn.dataset.role || 'user'; + + var hint = 'Select the new role for ' + escHTML(userName) + ':

' + + ''; + + ModAction.confirm('Set Role', hint, async () => { + // Use querySelector as fallback since Sanitizer may strip the id attribute + var selectEl = document.getElementById('set-role-select') || + document.querySelector('#mod-action-content select'); + if (!selectEl) throw new Error('Role selector not found in modal.'); + var role = selectEl.value; + var res = await fetch('/api/v2/admin/users/set-role', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: id, role }) + }); + var data = await res.json(); + if (data.success) { + showFlash(data.msg, 'success'); + // Update button state in place + btn.dataset.role = role; + var labelMap = { user: 'User', mod: 'Mod', admin: 'Admin' }; + var labelEl = document.querySelector('.role-label-' + id); + if (labelEl) labelEl.textContent = labelMap[role] || role; + } else { + throw new Error(data.msg || 'Failed to set role'); + } + }, { hideReason: true, confirmText: 'Apply Role', unsafeContent: true }); + }
diff --git a/views/admin/users_list.html b/views/admin/users_list.html index 6e8ca88..e898e6f 100644 --- a/views/admin/users_list.html +++ b/views/admin/users_list.html @@ -88,6 +88,7 @@ + @elseif(u.login === 'deleted_user') Protected System Account