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 @@ } + +
Administration hub for {!! total !!} registered members.