diff --git a/app.py b/app.py index 3b742f0..0e54a88 100644 --- a/app.py +++ b/app.py @@ -67,9 +67,12 @@ from config import ( # In-process state # --------------------------------------------------------------------------- -# sid → { username, ip, is_admin, joined_at, user_id, is_registered, +# sid → { username, ip, is_admin, role, joined_at, user_id, is_registered, # has_ai_access, ai_messages_used } connected_users: dict = {} + +# role hierarchy – higher number = more power +ROLE_POWER = {"user": 0, "mod": 1, "admin": 2, "root": 3} username_to_sid: dict = {} # lowercase_name → sid muted_users: set = set() banned_usernames: set = set() @@ -276,6 +279,7 @@ def _get_nicklist() -> list: "is_admin": info["is_admin"], "is_registered": info.get("is_registered", False), "is_verified": info.get("is_verified", False), + "role": info.get("role", "user"), }) # Static "Violet" AI user users.append({ @@ -283,7 +287,8 @@ def _get_nicklist() -> list: "is_admin": False, "is_registered": True, "is_verified": True, - "is_ai": True + "is_ai": True, + "role": "user", }) return sorted(users, key=lambda u: u["username"].lower()) @@ -299,6 +304,23 @@ def _require_admin(f): return wrapped +def _require_role(min_role): + """Decorator: require at least min_role power level.""" + def decorator(f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + user = connected_users.get(request.sid) + if not user: + emit("error", {"msg": "Forbidden."}); return + user_power = ROLE_POWER.get(user.get("role", "user"), 0) + needed = ROLE_POWER.get(min_role, 0) + if user_power < needed: + emit("error", {"msg": "Forbidden."}); return + return f(*args, **kwargs) + return wrapped + return decorator + + def _rate_limited(sid: str) -> bool: now = time.time() message_timestamps[sid] = [t for t in message_timestamps[sid] if now - t < RATE_WINDOW] @@ -425,6 +447,7 @@ def on_connect(auth=None): "username": None, "ip": ip, "is_admin": False, + "role": "user", "joined_at": time.time(), "user_id": user_id, "is_registered": is_registered, @@ -519,20 +542,31 @@ def on_join(data): if lower in username_to_sid and username_to_sid[lower] != sid: emit("error", {"msg": "Username already in use."}); return - is_admin = False - mod_pw = str(data.get("mod_password", "")).strip() - if mod_pw and mod_pw == ADMIN_PASSWORD: - is_admin = True + # Derive role from DB (root/admin/mod grant is_admin automatically) + db_role = db_user.role if db_user else "user" + is_admin = ROLE_POWER.get(db_role, 0) >= ROLE_POWER["mod"] - user["username"] = username - user["is_admin"] = is_admin - user["is_verified"] = db_user.is_verified if db_user else True # Guests are always "verified" for lobby + # Legacy mod-password fallback for guests (temporary mod access) + mod_pw = str(data.get("mod_password", "")).strip() + if mod_pw and mod_pw == ADMIN_PASSWORD and not is_admin: + is_admin = True + if db_role == "user": + db_role = "mod" # temporary elevation for the session + + user["username"] = username + user["is_admin"] = is_admin + user["role"] = db_role + user["is_verified"] = db_user.is_verified if db_user else True username_to_sid[lower] = sid join_room(LOBBY) + # Role badge for join message + role_icon = {"root": "👑 ", "admin": "⚔️ ", "mod": "🛡️ "}.get(db_role, "") + emit("joined", { "username": username, "is_admin": is_admin, + "role": db_role, "is_registered": user["is_registered"], "has_ai_access": user["has_ai_access"], "ai_messages_used": user["ai_messages_used"], @@ -541,7 +575,7 @@ def on_join(data): "ignored_list": [u.username for u in db_user.ignoring] if db_user else [] }) emit("system", { - "msg": f"{'🛡️ ' if is_admin else ''}**{username}** joined the room.", + "msg": f"{role_icon}**{username}** joined the room.", "ts": _ts(), }, to=LOBBY) socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY) @@ -908,3 +942,178 @@ def on_change_password(data): db_user.password_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") db.session.commit() emit("password_changed", {"success": True}) + + +# --------------------------------------------------------------------------- +# Admin panel +# --------------------------------------------------------------------------- + +@socketio.on("admin_get_users") +@_require_role("mod") +def on_admin_get_users(_data=None): + """Send the full user list to the admin panel.""" + users = User.query.order_by(User.id).all() + result = [] + for u in users: + if u.username == AI_BOT_NAME: + continue + online_sid = username_to_sid.get(u.username.lower()) + result.append({ + "id": u.id, + "username": u.username, + "role": u.role, + "is_verified": u.is_verified, + "has_ai_access": u.has_ai_access, + "email": u.email or "", + "created_at": u.created_at.strftime("%Y-%m-%d"), + "online": online_sid is not None, + }) + emit("admin_users", {"users": result}) + + +@socketio.on("admin_get_bans") +@_require_role("mod") +def on_admin_get_bans(_data=None): + bans = Ban.query.order_by(Ban.created_at.desc()).all() + emit("admin_bans", {"bans": [ + {"id": b.id, "username": b.username, "ip": b.ip or "", "reason": b.reason or "", + "created_at": b.created_at.strftime("%Y-%m-%d")} for b in bans + ]}) + + +@socketio.on("admin_get_mutes") +@_require_role("mod") +def on_admin_get_mutes(_data=None): + mutes = Mute.query.order_by(Mute.created_at.desc()).all() + emit("admin_mutes", {"mutes": [ + {"id": m.id, "username": m.username, + "created_at": m.created_at.strftime("%Y-%m-%d")} for m in mutes + ]}) + + +@socketio.on("admin_set_role") +@_require_role("admin") +def on_admin_set_role(data): + """Change a user's role. Only root can set admin/root. Admins can set mod/user.""" + sid = request.sid + me = connected_users.get(sid) + my_power = ROLE_POWER.get(me.get("role", "user"), 0) + + target_id = int(data.get("user_id", 0)) + new_role = str(data.get("role", "")).strip().lower() + if new_role not in ROLE_POWER: + emit("error", {"msg": "Invalid role."}); return + + target_power = ROLE_POWER[new_role] + if target_power >= my_power: + emit("error", {"msg": "Cannot assign a role equal/above your own."}); return + + target_user = db.session.get(User, target_id) + if not target_user: + emit("error", {"msg": "User not found."}); return + + # Can't change someone with equal or higher power + if ROLE_POWER.get(target_user.role, 0) >= my_power: + emit("error", {"msg": "Cannot modify a user with equal/higher privileges."}); return + + target_user.role = new_role + db.session.commit() + + # Update live session if they're online + target_sid = username_to_sid.get(target_user.username.lower()) + if target_sid and target_sid in connected_users: + connected_users[target_sid]["role"] = new_role + connected_users[target_sid]["is_admin"] = ROLE_POWER[new_role] >= ROLE_POWER["mod"] + # Notify the target user of their new role + socketio.emit("role_updated", {"role": new_role}, to=target_sid) + + socketio.emit("system", { + "msg": f"⚙️ **{target_user.username}** is now **{new_role}**.", + "ts": _ts() + }, to=LOBBY) + socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY) + emit("admin_action_ok", {"msg": f"{target_user.username} → {new_role}"}) + + +@socketio.on("admin_verify_user") +@_require_role("mod") +def on_admin_verify(data): + target_id = int(data.get("user_id", 0)) + target_user = db.session.get(User, target_id) + if not target_user: + emit("error", {"msg": "User not found."}); return + + target_user.is_verified = not target_user.is_verified + db.session.commit() + + status = "verified" if target_user.is_verified else "unverified" + + target_sid = username_to_sid.get(target_user.username.lower()) + if target_sid and target_sid in connected_users: + connected_users[target_sid]["is_verified"] = target_user.is_verified + + socketio.emit("system", { + "msg": f"{'✅' if target_user.is_verified else '❌'} **{target_user.username}** was {status}.", + "ts": _ts() + }, to=LOBBY) + socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY) + emit("admin_action_ok", {"msg": f"{target_user.username} → {status}"}) + + +@socketio.on("admin_toggle_ai") +@_require_role("admin") +def on_admin_toggle_ai(data): + target_id = int(data.get("user_id", 0)) + target_user = db.session.get(User, target_id) + if not target_user: + emit("error", {"msg": "User not found."}); return + + target_user.has_ai_access = not target_user.has_ai_access + db.session.commit() + + target_sid = username_to_sid.get(target_user.username.lower()) + if target_sid and target_sid in connected_users: + connected_users[target_sid]["has_ai_access"] = target_user.has_ai_access + + status = "granted" if target_user.has_ai_access else "revoked" + emit("admin_action_ok", {"msg": f"AI access {status} for {target_user.username}"}) + + +@socketio.on("admin_unban") +@_require_role("mod") +def on_admin_unban(data): + ban_id = int(data.get("ban_id", 0)) + ban = db.session.get(Ban, ban_id) + if not ban: + emit("error", {"msg": "Ban not found."}); return + + banned_usernames.discard(ban.username.lower()) + if ban.ip: + banned_ips.discard(ban.ip) + db.session.delete(ban) + db.session.commit() + + socketio.emit("system", { + "msg": f"🔓 **{ban.username}** was unbanned.", + "ts": _ts() + }, to=LOBBY) + emit("admin_action_ok", {"msg": f"Unbanned {ban.username}"}) + + +@socketio.on("admin_unmute") +@_require_role("mod") +def on_admin_unmute(data): + mute_id = int(data.get("mute_id", 0)) + mute = db.session.get(Mute, mute_id) + if not mute: + emit("error", {"msg": "Mute not found."}); return + + muted_users.discard(mute.username.lower()) + db.session.delete(mute) + db.session.commit() + + socketio.emit("system", { + "msg": f"🔊 **{mute.username}** was unmuted.", + "ts": _ts() + }, to=LOBBY) + emit("admin_action_ok", {"msg": f"Unmuted {mute.username}"}) diff --git a/index.html b/index.html index 31fea73..3e9d408 100644 --- a/index.html +++ b/index.html @@ -89,6 +89,11 @@
+
+ + + diff --git a/models.py b/models.py index 44e429e..8ddc408 100644 --- a/models.py +++ b/models.py @@ -22,6 +22,7 @@ class User(db.Model): username = db.Column(db.String(20), unique=True, nullable=False, index=True) password_hash = db.Column(db.String(128), nullable=False) email = db.Column(db.String(255), unique=True, nullable=True) + role = db.Column(db.String(10), default="user", nullable=False) # root, admin, mod, user has_ai_access = db.Column(db.Boolean, default=False, nullable=False) ai_messages_used = db.Column(db.Integer, default=0, nullable=False) is_verified = db.Column(db.Boolean, default=False, nullable=False) diff --git a/static/chat.js b/static/chat.js index 0a15a65..09b01e1 100644 --- a/static/chat.js +++ b/static/chat.js @@ -19,6 +19,7 @@ const socket = io({ const state = { username: null, isAdmin: false, + role: "user", isRegistered: false, hasAiAccess: false, aiMessagesUsed: 0, @@ -146,6 +147,7 @@ window.addEventListener("DOMContentLoaded", () => { socket.on("joined", (data) => { state.username = data.username; state.isAdmin = data.is_admin; + state.role = data.role || "user"; state.isRegistered = data.is_registered; state.hasAiAccess = data.has_ai_access; state.aiMessagesUsed = data.ai_messages_used; @@ -158,6 +160,10 @@ socket.on("joined", (data) => { joinScreen.classList.add("hidden"); chatScreen.classList.remove("hidden"); updateVioletBadge(); + + // Show admin panel button for mods+ + const adminBtn = $("admin-btn"); + if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin); }); socket.on("error", (data) => { @@ -522,10 +528,11 @@ function renderNicklist() { const li = document.createElement("li"); const isIgnored = state.ignoredUsers.has(u.username); const isUnverified = u.is_registered && !u.is_verified; + const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || ""; li.innerHTML = ` - ${u.is_admin ? ' ' : ''} + ${roleIcon ? `${roleIcon} ` : ''} ${u.username} ${u.is_registered ? '' : ''} ${isIgnored ? ' (ignored)' : ''} @@ -600,6 +607,9 @@ function executeMenuAction(action, target) { case "kick": socket.emit("mod_kick", { target }); break; + case "mute": + socket.emit("mod_mute", { target }); + break; case "ban": socket.emit("mod_ban", { target }); break; @@ -818,3 +828,182 @@ function escapeHTML(str) { p.textContent = str; return p.innerHTML; } + +// ── Role updated (live notification) ────────────────────────────────────── + +socket.on("role_updated", (data) => { + state.role = data.role; + state.isAdmin = ["mod", "admin", "root"].includes(data.role); + const adminBtn = $("admin-btn"); + if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin); +}); + +// ── Admin Panel ─────────────────────────────────────────────────────────── + +const adminModal = $("admin-modal"); +const ROLE_POWER = { user: 0, mod: 1, admin: 2, root: 3 }; + +if ($("admin-btn")) { + $("admin-btn").onclick = () => { + adminModal.classList.remove("hidden"); + socket.emit("admin_get_users"); + socket.emit("admin_get_bans"); + socket.emit("admin_get_mutes"); + }; +} + +$("close-admin").onclick = () => adminModal.classList.add("hidden"); +adminModal.addEventListener("click", (e) => { + if (e.target === adminModal) adminModal.classList.add("hidden"); +}); + +// Admin tab switching +document.querySelectorAll(".admin-tab").forEach(tab => { + tab.addEventListener("click", () => { + document.querySelectorAll(".admin-tab").forEach(t => t.classList.remove("active")); + document.querySelectorAll(".admin-pane").forEach(p => p.classList.remove("active")); + tab.classList.add("active"); + $("atab-" + tab.dataset.atab).classList.add("active"); + }); +}); + +// Admin toast helper +function adminToast(msg) { + const t = $("admin-toast"); + t.textContent = msg; + t.classList.remove("hidden"); + setTimeout(() => t.classList.add("hidden"), 2500); +} + +socket.on("admin_action_ok", (data) => adminToast(data.msg)); + +// ── Users pane ─────────────────────────────────────────────────────────── + +let adminUserCache = []; + +socket.on("admin_users", (data) => { + adminUserCache = data.users; + renderAdminUsers(adminUserCache); +}); + +$("admin-user-search").addEventListener("input", (e) => { + const q = e.target.value.toLowerCase(); + renderAdminUsers(adminUserCache.filter(u => u.username.toLowerCase().includes(q))); +}); + +function renderAdminUsers(users) { + const list = $("admin-user-list"); + list.innerHTML = ""; + const myPower = ROLE_POWER[state.role] || 0; + + users.forEach(u => { + const row = document.createElement("div"); + row.className = "admin-user-row"; + + const canEditRole = myPower > ROLE_POWER[u.role] && myPower >= ROLE_POWER.admin; + const canVerify = myPower >= ROLE_POWER.mod; + const canToggleAI = myPower >= ROLE_POWER.admin && u.role !== "root"; + + // Build role selector + let roleHTML = ""; + if (canEditRole) { + const opts = ["user", "mod"]; + if (myPower >= ROLE_POWER.root) opts.push("admin"); + roleHTML = ``; + } else { + roleHTML = `${u.role}`; + } + + row.innerHTML = ` + ${escapeHTML(u.username)} + ${roleHTML} + + ${u.online ? 'online' : ''} + ${u.is_verified ? 'verified' : 'unverified'} + ${u.has_ai_access ? 'AI' : ''} + + + ${canVerify ? `` : ''} + ${canToggleAI ? `` : ''} + + `; + list.appendChild(row); + }); + + // Event delegation for actions + list.onclick = (e) => { + const btn = e.target.closest("[data-action]"); + if (!btn) return; + const uid = parseInt(btn.dataset.uid); + const action = btn.dataset.action; + if (action === "verify") socket.emit("admin_verify_user", { user_id: uid }); + else if (action === "toggle-ai") socket.emit("admin_toggle_ai", { user_id: uid }); + }; + list.onchange = (e) => { + const sel = e.target.closest("[data-action='set-role']"); + if (!sel) return; + const uid = parseInt(sel.dataset.uid); + socket.emit("admin_set_role", { user_id: uid, role: sel.value }); + }; +} + +// ── Bans pane ──────────────────────────────────────────────────────────── + +socket.on("admin_bans", (data) => { + const list = $("admin-ban-list"); + const empty = $("admin-no-bans"); + list.innerHTML = ""; + empty.classList.toggle("hidden", data.bans.length > 0); + + data.bans.forEach(b => { + const row = document.createElement("div"); + row.className = "admin-ban-row"; + row.innerHTML = ` +
+ ${escapeHTML(b.username)} + ${b.ip ? `${b.ip}` : ''} + ${b.created_at} +
+ + `; + list.appendChild(row); + }); + + list.onclick = (e) => { + const btn = e.target.closest("[data-bid]"); + if (!btn) return; + socket.emit("admin_unban", { ban_id: parseInt(btn.dataset.bid) }); + btn.closest(".admin-ban-row").remove(); + }; +}); + +// ── Mutes pane ─────────────────────────────────────────────────────────── + +socket.on("admin_mutes", (data) => { + const list = $("admin-mute-list"); + const empty = $("admin-no-mutes"); + list.innerHTML = ""; + empty.classList.toggle("hidden", data.mutes.length > 0); + + data.mutes.forEach(m => { + const row = document.createElement("div"); + row.className = "admin-mute-row"; + row.innerHTML = ` +
+ ${escapeHTML(m.username)} + ${m.created_at} +
+ + `; + list.appendChild(row); + }); + + list.onclick = (e) => { + const btn = e.target.closest("[data-mid]"); + if (!btn) return; + socket.emit("admin_unmute", { mute_id: parseInt(btn.dataset.mid) }); + btn.closest(".admin-mute-row").remove(); + }; +}); diff --git a/static/style.css b/static/style.css index 113e7d0..36f8a0e 100644 --- a/static/style.css +++ b/static/style.css @@ -970,6 +970,189 @@ textarea { /* ── Mobile Overrides ─────────────────────────────────────────────────────── */ +/* ── Admin Panel ──────────────────────────────────────────────────────────── */ +.admin-card { + width: 100%; + max-width: 640px; + max-height: 85vh; + border-radius: 24px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem 0; +} + +.admin-header h2 { font-size: 1.4rem; font-weight: 700; } + +.admin-tabs { + display: flex; + margin: 1rem 2rem 0; + background: rgba(0, 0, 0, 0.3); + padding: 3px; + border-radius: 10px; + gap: 2px; +} + +.admin-tab { + flex: 1; + padding: 7px 4px; + border: none; + background: transparent; + color: var(--text-dim); + font-weight: 600; + font-size: 0.8rem; + cursor: pointer; + border-radius: 8px; + transition: all 0.2s; +} + +.admin-tab.active { + background: var(--accent-purple); + color: white; + box-shadow: 0 2px 8px var(--accent-glow); +} + +.admin-body { + padding: 1rem 2rem 1.5rem; + overflow-y: auto; + flex: 1; +} + +.admin-pane { display: none; } +.admin-pane.active { display: block; } + +.admin-search-wrap { margin-bottom: 0.75rem; } + +.admin-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 50vh; + overflow-y: auto; +} + +.admin-user-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--glass-border); + border-radius: 12px; + font-size: 0.85rem; + flex-wrap: wrap; +} + +.admin-user-row .au-name { + font-weight: 700; + color: var(--text-main); + min-width: 90px; +} + +.admin-user-row .au-role { + font-size: 0.7rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.au-role.root { background: rgba(255, 215, 0, 0.2); color: #ffd700; } +.au-role.admin { background: rgba(255, 0, 255, 0.15); color: var(--accent-magenta); } +.au-role.mod { background: rgba(0, 200, 83, 0.15); color: #00c853; } +.au-role.user { background: rgba(255, 255, 255, 0.08); color: var(--text-dim); } + +.admin-user-row .au-badges { + display: flex; + gap: 4px; + flex: 1; +} + +.au-badge { + font-size: 0.65rem; + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; +} + +.au-badge.online { background: rgba(0, 255, 170, 0.15); color: var(--success-green); } +.au-badge.verified { background: rgba(0, 200, 255, 0.1); color: var(--ai-teal); } +.au-badge.unverified { background: rgba(255, 51, 102, 0.15); color: var(--error-red); } +.au-badge.ai-on { background: rgba(138, 43, 226, 0.15); color: var(--accent-purple); } + +.admin-user-row .au-actions { + display: flex; + gap: 4px; + margin-left: auto; +} + +.au-btn { + padding: 4px 10px; + border: 1px solid var(--glass-border); + background: transparent; + color: var(--text-dim); + border-radius: 6px; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.au-btn:hover { background: var(--accent-purple); color: white; border-color: var(--accent-purple); } +.au-btn.danger:hover { background: var(--error-red); border-color: var(--error-red); } + +.au-select { + padding: 3px 6px; + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--glass-border); + color: var(--text-main); + border-radius: 6px; + font-size: 0.7rem; + cursor: pointer; + outline: none; +} + +.admin-ban-row, .admin-mute-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--glass-border); + border-radius: 12px; + font-size: 0.85rem; +} + +.admin-ban-row .ab-name, .admin-mute-row .am-name { + font-weight: 700; + color: var(--error-red); +} + +.admin-empty { + text-align: center; + color: var(--text-dim); + font-size: 0.85rem; + padding: 2rem 0; +} + +.admin-toast { + margin: 0 2rem 1rem; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.85rem; + background: rgba(0, 255, 170, 0.15); + color: var(--success-green); + text-align: center; + transition: opacity 0.3s; +} + /* ── Theme Picker ─────────────────────────────────────────────────────────── */ .theme-grid { display: grid;