forked from ComputerTech/aprhodite
Add role-based admin panel with root/admin/mod/user hierarchy
- User model: new 'role' column (root > admin > mod > user) - End3r (id=2) set as 'root' (GOD admin) - Admin panel modal: Users tab (search, set roles, verify, grant AI), Bans tab (list/unban), Mutes tab (list/unmute) - Role-based permission checks: root can set admins, admins set mods, mods can kick/ban/mute/verify - Shield icon in header (visible to mod+) opens admin panel - Nicklist shows role icons: crown (root), swords (admin), shield (mod) - Context menu: added Mute/Unmute action - Live role_updated event pushes role changes to online users - role_power hierarchy prevents privilege escalation
This commit is contained in:
parent
064f6bf0ba
commit
887482d3db
221
app.py
221
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
|
||||
# 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"]
|
||||
|
||||
# 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:
|
||||
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["is_verified"] = db_user.is_verified if db_user else True # Guests are always "verified" for lobby
|
||||
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}"})
|
||||
|
|
|
|||
47
index.html
47
index.html
|
|
@ -89,6 +89,11 @@
|
|||
<div class="header-right">
|
||||
<span id="violet-trial-badge" class="violet-badge hidden"></span>
|
||||
<span id="my-username-badge" class="my-badge"></span>
|
||||
<button id="admin-btn" class="icon-btn hidden" aria-label="Admin Panel" title="Admin Panel">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="settings-btn" class="icon-btn" aria-label="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
|
|
@ -345,6 +350,46 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Panel Modal -->
|
||||
<div id="admin-modal" class="modal-overlay hidden">
|
||||
<div class="admin-card glass">
|
||||
<div class="admin-header">
|
||||
<h2>👑 Admin Panel</h2>
|
||||
<button id="close-admin" class="settings-close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-atab="users">Users</button>
|
||||
<button class="admin-tab" data-atab="bans">Bans</button>
|
||||
<button class="admin-tab" data-atab="mutes">Mutes</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-body">
|
||||
<!-- Users pane -->
|
||||
<div class="admin-pane active" id="atab-users">
|
||||
<div id="admin-user-search-wrap" class="admin-search-wrap">
|
||||
<input id="admin-user-search" type="text" class="settings-input" placeholder="Search users..." />
|
||||
</div>
|
||||
<div id="admin-user-list" class="admin-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bans pane -->
|
||||
<div class="admin-pane" id="atab-bans">
|
||||
<div id="admin-ban-list" class="admin-list"></div>
|
||||
<p id="admin-no-bans" class="admin-empty">No active bans.</p>
|
||||
</div>
|
||||
|
||||
<!-- Mutes pane -->
|
||||
<div class="admin-pane" id="atab-mutes">
|
||||
<div id="admin-mute-list" class="admin-list"></div>
|
||||
<p id="admin-no-mutes" class="admin-empty">No active mutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-toast" class="admin-toast hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu glass hidden">
|
||||
<div class="menu-item" data-action="pm">Private Message</div>
|
||||
|
|
@ -354,7 +399,7 @@
|
|||
<div class="menu-divider mod-item hidden"></div>
|
||||
<div class="menu-item mod-item hidden" data-action="verify">Verify User</div>
|
||||
<div class="menu-item mod-item hidden" data-action="kick">Kick</div>
|
||||
|
||||
<div class="menu-item mod-item hidden" data-action="mute">Mute / Unmute</div>
|
||||
<div class="menu-item mod-item red hidden" data-action="ban">Ban</div>
|
||||
<div class="menu-item mod-item red bold hidden" data-action="kickban">Kickban</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
191
static/chat.js
191
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 = `
|
||||
<span class="${isUnverified ? 'unverified' : ''}">
|
||||
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
|
||||
${roleIcon ? `<span class="mod-star">${roleIcon}</span> ` : ''}
|
||||
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
|
||||
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
|
||||
${isIgnored ? ' <small>(ignored)</small>' : ''}
|
||||
|
|
@ -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 = `<select class="au-select" data-uid="${u.id}" data-action="set-role">
|
||||
${opts.map(r => `<option value="${r}" ${u.role === r ? "selected" : ""}>${r}</option>`).join("")}
|
||||
</select>`;
|
||||
} else {
|
||||
roleHTML = `<span class="au-role ${u.role}">${u.role}</span>`;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="au-name">${escapeHTML(u.username)}</span>
|
||||
${roleHTML}
|
||||
<span class="au-badges">
|
||||
${u.online ? '<span class="au-badge online">online</span>' : ''}
|
||||
${u.is_verified ? '<span class="au-badge verified">verified</span>' : '<span class="au-badge unverified">unverified</span>'}
|
||||
${u.has_ai_access ? '<span class="au-badge ai-on">AI</span>' : ''}
|
||||
</span>
|
||||
<span class="au-actions">
|
||||
${canVerify ? `<button class="au-btn" data-uid="${u.id}" data-action="verify">${u.is_verified ? 'Unverify' : 'Verify'}</button>` : ''}
|
||||
${canToggleAI ? `<button class="au-btn" data-uid="${u.id}" data-action="toggle-ai">${u.has_ai_access ? 'Revoke AI' : 'Grant AI'}</button>` : ''}
|
||||
</span>
|
||||
`;
|
||||
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 = `
|
||||
<div>
|
||||
<span class="ab-name">${escapeHTML(b.username)}</span>
|
||||
${b.ip ? `<small style="color:var(--text-dim);margin-left:8px">${b.ip}</small>` : ''}
|
||||
<small style="color:var(--text-dim);margin-left:8px">${b.created_at}</small>
|
||||
</div>
|
||||
<button class="au-btn danger" data-bid="${b.id}">Unban</button>
|
||||
`;
|
||||
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 = `
|
||||
<div>
|
||||
<span class="am-name">${escapeHTML(m.username)}</span>
|
||||
<small style="color:var(--text-dim);margin-left:8px">${m.created_at}</small>
|
||||
</div>
|
||||
<button class="au-btn danger" data-mid="${m.id}">Unmute</button>
|
||||
`;
|
||||
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();
|
||||
};
|
||||
});
|
||||
|
|
|
|||
183
static/style.css
183
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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue