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:
3nd3r 2026-04-12 14:39:43 -05:00
parent 064f6bf0ba
commit 887482d3db
5 changed files with 639 additions and 12 deletions

221
app.py
View File

@ -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}"})

View File

@ -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">&times;</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>

View File

@ -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)

View File

@ -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();
};
});

View File

@ -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;