diff --git a/backend/main.py b/backend/main.py
index 7f55e9d..e1a496b 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -235,6 +235,29 @@
);
CREATE INDEX IF NOT EXISTS idx_group_invites_invitee ON group_invites(invitee_id, expires_at);
CREATE INDEX IF NOT EXISTS idx_group_invites_group ON group_invites(group_id);
+CREATE TABLE IF NOT EXISTS mod_actions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ mod_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ target_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ action TEXT NOT NULL,
+ reason TEXT,
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_mod_actions_mod ON mod_actions(mod_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_mod_actions_target ON mod_actions(target_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_mod_actions_time ON mod_actions(created_at DESC);
+CREATE TABLE IF NOT EXISTS appeals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'pending',
+ reason TEXT NOT NULL,
+ response TEXT,
+ created_at INTEGER NOT NULL,
+ reviewed_at INTEGER,
+ reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL
+);
+CREATE INDEX IF NOT EXISTS idx_appeals_user ON appeals(user_id);
+CREATE INDEX IF NOT EXISTS idx_appeals_status ON appeals(status, created_at DESC);
"""
@@ -477,7 +500,7 @@ class ReportIn(BaseModel):
reportedUserId: Optional[int] = None
messageId: Optional[int] = None
groupMessageId: Optional[int] = None
- reason: str = Field(min_length=5, max_length=500)
+ reason: str = Field(min_length=1, max_length=500)
details: Optional[str] = Field(default=None, max_length=2000)
@@ -1594,6 +1617,16 @@ def mod_user_status(user_id: int, body: ModUserActionIn, user = Depends(require_
(new_status, suspended_until, user_id)
)
+ # Log action to audit log
+ action_str = body.action
+ if body.action == "suspend" and body.duration_days:
+ action_str = f"suspend_{body.duration_days}d"
+
+ conn.execute(
+ "INSERT INTO mod_actions (mod_id, target_id, action, reason, created_at) VALUES (?, ?, ?, ?, ?)",
+ (user["id"], user_id, action_str, body.reason, now)
+ )
+
# Revoke sessions if suspending or banning
if new_status in ("banned", "suspended"):
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
@@ -1608,15 +1641,189 @@ def mod_change_role(user_id: int, body: ModChangeRoleIn, user = Depends(require_
if not is_super_moderator(user):
raise HTTPException(403, "Only super moderators can change roles")
+ now = int(time.time())
with db() as conn:
target = conn.execute("SELECT id, role FROM users WHERE id = ?", (user_id,)).fetchone()
if not target:
raise HTTPException(404, "User not found")
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.new_role, user_id))
+ # Log action to audit log
+ conn.execute(
+ "INSERT INTO mod_actions (mod_id, target_id, action, reason, created_at) VALUES (?, ?, ?, ?, ?)",
+ (user["id"], user_id, f"role_change_{body.new_role}", f"Changed from {target['role']}", now)
+ )
+
return {"ok": True, "newRole": body.new_role}
+@app.get("/api/mod/audit-log")
+def get_audit_log(user = Depends(require_moderator), limit: int = 100, offset: int = 0):
+ """Get audit log of moderator actions. Limited to 100 entries by default."""
+ if limit > 500:
+ limit = 500
+ if limit < 1:
+ limit = 1
+ if offset < 0:
+ offset = 0
+
+ with db() as conn:
+ # Get total count
+ total = conn.execute("SELECT COUNT(*) as cnt FROM mod_actions").fetchone()["cnt"]
+
+ # Get audit log entries ordered by most recent first
+ rows = conn.execute(
+ """SELECT ma.id, ma.mod_id, ma.target_id, ma.action, ma.reason, ma.created_at,
+ mod_user.username as mod_username,
+ target_user.username as target_username
+ FROM mod_actions ma
+ LEFT JOIN users mod_user ON ma.mod_id = mod_user.id
+ LEFT JOIN users target_user ON ma.target_id = target_user.id
+ ORDER BY ma.created_at DESC
+ LIMIT ? OFFSET ?""",
+ (limit, offset)
+ ).fetchall()
+
+ entries = [
+ {
+ "id": r["id"],
+ "modId": r["mod_id"],
+ "modUsername": r["mod_username"],
+ "targetId": r["target_id"],
+ "targetUsername": r["target_username"],
+ "action": r["action"],
+ "reason": r["reason"],
+ "createdAt": r["created_at"]
+ }
+ for r in rows
+ ]
+
+ return {
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "entries": entries
+ }
+
+
+@app.post("/api/appeals")
+def create_appeal(body: ModUserActionIn, user = Depends(auth_dep)):
+ """Allow suspended/banned users to appeal their status."""
+ now = int(time.time())
+ with db() as conn:
+ user_row = conn.execute("SELECT id, status FROM users WHERE id = ?", (user["id"],)).fetchone()
+ if user_row["status"] == "active":
+ raise HTTPException(400, "Only suspended or banned users can appeal")
+
+ # Check if user already has a pending appeal
+ existing = conn.execute(
+ "SELECT id FROM appeals WHERE user_id = ? AND status = 'pending'",
+ (user["id"],)
+ ).fetchone()
+ if existing:
+ raise HTTPException(400, "You already have a pending appeal")
+
+ # Create appeal
+ conn.execute(
+ "INSERT INTO appeals (user_id, status, reason, created_at) VALUES (?, ?, ?, ?)",
+ (user["id"], "pending", body.reason, now)
+ )
+
+ return {"ok": True, "message": "Appeal submitted successfully"}
+
+
+@app.get("/api/mod/appeals")
+def get_appeals(user = Depends(require_moderator), status: str = "pending", limit: int = 50, offset: int = 0):
+ """Get appeals for moderator review."""
+ if limit > 500:
+ limit = 500
+ if limit < 1:
+ limit = 1
+ if offset < 0:
+ offset = 0
+
+ with db() as conn:
+ # Get total count
+ query = "SELECT COUNT(*) as cnt FROM appeals"
+ params = []
+ if status:
+ query += " WHERE status = ?"
+ params.append(status)
+ total = conn.execute(query, params).fetchone()["cnt"]
+
+ # Get appeals
+ query = """SELECT a.id, a.user_id, a.status, a.reason, a.response, a.created_at, a.reviewed_at,
+ a.reviewed_by, u.email
+ FROM appeals a
+ LEFT JOIN users u ON a.user_id = u.id"""
+ if status:
+ query += " WHERE a.status = ?"
+ query += " ORDER BY a.created_at DESC LIMIT ? OFFSET ?"
+ params.extend([limit, offset])
+
+ rows = conn.execute(query, params).fetchall()
+
+ entries = [
+ {
+ "id": r["id"],
+ "userId": r["user_id"],
+ "userEmail": r["email"],
+ "status": r["status"],
+ "reason": r["reason"],
+ "response": r["response"],
+ "createdAt": r["created_at"],
+ "reviewedAt": r["reviewed_at"]
+ }
+ for r in rows
+ ]
+
+ return {
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "entries": entries
+ }
+
+
+@app.post("/api/mod/appeals/{appeal_id}")
+def review_appeal(appeal_id: int, body: ModUserActionIn, user = Depends(require_moderator)):
+ """Review and approve/reject an appeal."""
+ if body.action not in ("approve", "reject"):
+ raise HTTPException(400, "Action must be 'approve' or 'reject'")
+
+ now = int(time.time())
+ with db() as conn:
+ appeal = conn.execute("SELECT user_id, status FROM appeals WHERE id = ?", (appeal_id,)).fetchone()
+ if not appeal:
+ raise HTTPException(404, "Appeal not found")
+ if appeal["status"] != "pending":
+ raise HTTPException(400, "Appeal already reviewed")
+
+ if body.action == "approve":
+ # Restore user
+ conn.execute(
+ "UPDATE users SET status = 'active', suspended_until = NULL WHERE id = ?",
+ (appeal["user_id"],)
+ )
+ new_status = "approved"
+ else:
+ new_status = "rejected"
+
+ # Update appeal
+ conn.execute(
+ "UPDATE appeals SET status = ?, response = ?, reviewed_at = ?, reviewed_by = ? WHERE id = ?",
+ (new_status, body.reason, now, user["id"], appeal_id)
+ )
+
+ # Log action
+ conn.execute(
+ "INSERT INTO mod_actions (mod_id, target_id, action, reason, created_at) VALUES (?, ?, ?, ?, ?)",
+ (user["id"], appeal["user_id"], f"appeal_{new_status}", body.reason, now)
+ )
+
+ return {"ok": True, "status": new_status}
+
+
# ── Session management ────────────────────────────────────────────────────────────
@app.post("/api/auth/logout-all")
diff --git a/static/index.html b/static/index.html
index 0811c2f..b603f8a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -356,21 +356,53 @@
Danger zone
-
+
+
User accounts
Suspend or ban an account to stop abusive activity.
+
+
+
+
+
+
+ Pending appeals
+ Users can appeal their suspension or ban with a written reason.
+
+
+
+
+ Audit log
+ All moderator actions are logged for transparency and accountability.
+
+
@@ -501,6 +533,113 @@ Start something new
+
+
+
+
+
+
+
Appeal reason:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/js/app.js b/static/js/app.js
index 637d80b..95ea84c 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -59,6 +59,93 @@ function confirmDialog({ title, body, okText = "Confirm", danger = true }) {
});
}
+function suspensionDialog() {
+ return new Promise((resolve) => {
+ const m = $("suspension-modal");
+ const form = $("suspension-form");
+ const duration = $("suspension-duration");
+ const template = $("suspension-template");
+ const reason = $("suspension-reason");
+ const ok = $("suspension-ok");
+ const cancel = $("suspension-cancel");
+
+ form.reset();
+
+ const onTemplateChange = () => {
+ if (template.value) {
+ reason.value = template.value;
+ }
+ };
+
+ const close = (v) => {
+ m.classList.add("hidden");
+ ok.removeEventListener("click", onOk);
+ cancel.removeEventListener("click", onCancel);
+ form.removeEventListener("submit", onSubmit);
+ template.removeEventListener("change", onTemplateChange);
+ resolve(v);
+ };
+ const onOk = () => {
+ if (!duration.value) {
+ duration.focus();
+ return;
+ }
+ close({ durationDays: Number(duration.value), reason: reason.value || null });
+ };
+ const onCancel = () => close(null);
+ const onSubmit = (e) => { e.preventDefault(); onOk(); };
+
+ ok.addEventListener("click", onOk);
+ cancel.addEventListener("click", onCancel);
+ form.addEventListener("submit", onSubmit);
+ template.addEventListener("change", onTemplateChange);
+ m.classList.remove("hidden");
+ duration.focus();
+ });
+}
+
+function actionDialog(title) {
+ return new Promise((resolve) => {
+ const m = $("action-modal");
+ const form = $("action-form");
+ const template = $("action-template");
+ const reason = $("action-reason");
+ const ok = $("action-ok");
+ const cancel = $("action-cancel");
+ const titleEl = $("action-modal-title");
+
+ titleEl.textContent = title;
+ form.reset();
+
+ const onTemplateChange = () => {
+ if (template.value) {
+ reason.value = template.value;
+ }
+ };
+
+ const close = (v) => {
+ m.classList.add("hidden");
+ ok.removeEventListener("click", onOk);
+ cancel.removeEventListener("click", onCancel);
+ form.removeEventListener("submit", onSubmit);
+ template.removeEventListener("change", onTemplateChange);
+ resolve(v);
+ };
+ const onOk = () => {
+ close({ reason: reason.value || null });
+ };
+ const onCancel = () => close(null);
+ const onSubmit = (e) => { e.preventDefault(); onOk(); };
+
+ ok.addEventListener("click", onOk);
+ cancel.addEventListener("click", onCancel);
+ form.addEventListener("submit", onSubmit);
+ template.addEventListener("change", onTemplateChange);
+ m.classList.remove("hidden");
+ template.focus();
+ });
+}
+
function fmtTime(unix) {
if (!unix) return "—";
const d = new Date(unix * 1000);
@@ -745,134 +832,315 @@ async function loadModeration() {
$("mod-total-messages").textContent = stats.totalMessages;
$("mod-total-reports").textContent = stats.totalReports;
+ // Get pending appeals count for badge
+ const appealsData = await api.get("/api/mod/appeals?status=pending&limit=1");
+ const pendingAppealCount = appealsData.total;
+ $("mod-pending-appeals").textContent = pendingAppealCount;
+ const appealsBadge = $("mod-appeals-badge");
+ if (pendingAppealCount > 0) {
+ appealsBadge.textContent = pendingAppealCount > 99 ? "99+" : pendingAppealCount;
+ appealsBadge.style.display = "flex";
+ } else {
+ appealsBadge.style.display = "none";
+ }
+
const reports = await api.get("/api/mod/reports?limit=100");
const reportsList = $("mod-reports-list");
- reportsList.replaceChildren();
- if (reports.length === 0) {
+ const searchInput = $("mod-reports-search");
+
+ const renderReports = () => {
+ const searchTerm = searchInput.value.toLowerCase();
+ let filtered = reports.filter(r => {
+ const searchText = [
+ r.reason,
+ r.details || "",
+ r.reporterEmail || "",
+ r.reportedUserEmail || ""
+ ].join(" ").toLowerCase();
+ return searchText.includes(searchTerm);
+ });
+
+ reportsList.replaceChildren();
+ if (filtered.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "empty";
+ const p = document.createElement("p");
+ p.textContent = filtered.length !== reports.length ? "No matching reports." : "No recent reports.";
+ empty.appendChild(p);
+ reportsList.appendChild(empty);
+ } else {
+ filtered.forEach(r => {
+ const reportRow = document.createElement("div");
+ reportRow.className = "report-row";
+ reportRow.style.cssText = "padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.08);";
+
+ const line1 = document.createElement("div");
+ const reasonB = document.createElement("b");
+ reasonB.textContent = r.reason;
+ line1.appendChild(reasonB);
+ line1.appendChild(document.createTextNode(" · " + fmtTime(r.createdAt)));
+ reportRow.appendChild(line1);
+
+ const line2 = document.createElement("div");
+ line2.className = "muted small";
+ line2.textContent = "Reporter: " + (r.reporterEmail || "unknown") + " · Reported: " + (r.reportedUserEmail || "unknown");
+ reportRow.appendChild(line2);
+
+ const line3 = document.createElement("div");
+ line3.textContent = r.details || "No additional details.";
+ reportRow.appendChild(line3);
+
+ const line4 = document.createElement("div");
+ line4.className = "muted small";
+ line4.textContent = "DM " + (r.messageId ? "id " + r.messageId : r.groupMessageId ? "group id " + r.groupMessageId : "user report");
+ reportRow.appendChild(line4);
+
+ reportsList.appendChild(reportRow);
+ });
+ }
+ };
+
+ renderReports();
+ searchInput.addEventListener("input", renderReports);
+
+ const users = await api.get("/api/mod/users?limit=200");
+ const searchInput = $("mod-user-search");
+ const filterSelect = $("mod-user-filter");
+
+ const renderUsersList = () => {
+ const searchTerm = searchInput.value.toLowerCase();
+ const filterStatus = filterSelect.value;
+
+ let filtered = users.filter(u => {
+ const matchesSearch = u.email.toLowerCase().includes(searchTerm);
+ const matchesFilter = !filterStatus ||
+ (filterStatus === "moderator" ? u.role === "moderator" : u.status === filterStatus);
+ return matchesSearch && matchesFilter;
+ });
+
+ const usersList = $("mod-users-list");
+ usersList.replaceChildren();
+
+ if (filtered.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "empty";
+ const p = document.createElement("p");
+ p.textContent = filtered.length !== users.length ? "No matching users." : "No user accounts.";
+ empty.appendChild(p);
+ usersList.appendChild(empty);
+ } else {
+ filtered.forEach(u => {
+ const sessionItem = document.createElement("div");
+ sessionItem.className = "session-item";
+ sessionItem.style.cssText = "display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;";
+
+ const leftDiv = document.createElement("div");
+ leftDiv.style.cssText = "min-width:0;";
+
+ const emailDiv = document.createElement("div");
+ const emailB = document.createElement("b");
+ emailB.textContent = u.email;
+ emailDiv.appendChild(emailB);
+ if (u.role === "moderator") {
+ const hint = document.createElement("span");
+ hint.className = "hint";
+ hint.textContent = "(moderator)";
+ emailDiv.appendChild(document.createTextNode(" "));
+ emailDiv.appendChild(hint);
+ }
+ leftDiv.appendChild(emailDiv);
+
+ const statusDiv = document.createElement("div");
+ statusDiv.className = "muted small";
+ statusDiv.textContent = "Status: " + u.status + " · Created " + fmtTime(u.createdAt) + " · Last login " + fmtTime(u.lastLoginAt);
+ leftDiv.appendChild(statusDiv);
+
+ sessionItem.appendChild(leftDiv);
+
+ const rightDiv = document.createElement("div");
+ rightDiv.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;";
+
+ if (u.status !== "suspended" && u.status !== "banned") {
+ const suspendBtn = document.createElement("button");
+ suspendBtn.className = "btn ghost";
+ suspendBtn.setAttribute("data-mod-action", "suspend");
+ suspendBtn.setAttribute("data-user-id", u.id);
+ suspendBtn.textContent = "Suspend";
+ rightDiv.appendChild(suspendBtn);
+
+ const banBtn = document.createElement("button");
+ banBtn.className = "btn ghost";
+ banBtn.setAttribute("data-mod-action", "ban");
+ banBtn.setAttribute("data-user-id", u.id);
+ banBtn.textContent = "Ban";
+ rightDiv.appendChild(banBtn);
+ }
+
+ if (u.status !== "active") {
+ const restoreBtn = document.createElement("button");
+ restoreBtn.className = "btn primary";
+ restoreBtn.setAttribute("data-mod-action", "restore");
+ restoreBtn.setAttribute("data-user-id", u.id);
+ restoreBtn.textContent = "Restore";
+ rightDiv.appendChild(restoreBtn);
+ }
+
+ sessionItem.appendChild(rightDiv);
+ usersList.appendChild(sessionItem);
+ });
+
+ for (const btn of usersList.querySelectorAll("[data-mod-action]")) {
+ btn.addEventListener("click", async () => {
+ try {
+ const action = btn.dataset.modAction;
+ const userId = Number(btn.dataset.userId);
+
+ let actionData = null;
+ if (action === "suspend") {
+ actionData = await suspensionDialog();
+ if (!actionData) return;
+ } else if (action === "ban" || action === "restore") {
+ const title = action === "ban" ? "Ban this account?" : "Restore this account?";
+ actionData = await actionDialog(title);
+ if (!actionData) return;
+ }
+
+ const payload = { action, ...(actionData && actionData.durationDays ? { duration_days: actionData.durationDays } : {}), ...(actionData && actionData.reason ? { reason: actionData.reason } : {}) };
+ await api.post(`/api/mod/users/${userId}/status`, payload);
+ const actionLabel = { suspend: "suspended", ban: "banned", restore: "restored" }[action];
+ toast(`Account ${actionLabel}`, "info");
+ loadModeration();
+ } catch (e) {
+ toast(`Error: ${e.message}`, "error");
+ }
+ });
+ }
+ }
+ };
+
+ renderUsersList();
+ searchInput.addEventListener("input", renderUsersList);
+ filterSelect.addEventListener("change", renderUsersList);
+
+ const appeals = await api.get("/api/mod/appeals?status=pending&limit=50");
+ const appealsList = $("mod-appeals-list");
+ appealsList.replaceChildren();
+ if (appeals.entries.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
const p = document.createElement("p");
- p.textContent = "No recent reports.";
+ p.textContent = "No pending appeals.";
empty.appendChild(p);
- reportsList.appendChild(empty);
+ appealsList.appendChild(empty);
} else {
- reports.forEach(r => {
- const reportRow = document.createElement("div");
- reportRow.className = "report-row";
- reportRow.style.cssText = "padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.08);";
-
+ appeals.entries.forEach(appeal => {
+ const row = document.createElement("div");
+ row.className = "report-row";
+ row.style.cssText = "padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.08);";
+
const line1 = document.createElement("div");
- const reasonB = document.createElement("b");
- reasonB.textContent = r.reason;
- line1.appendChild(reasonB);
- line1.appendChild(document.createTextNode(" · " + fmtTime(r.createdAt)));
- reportRow.appendChild(line1);
-
+ const userB = document.createElement("b");
+ userB.textContent = appeal.userEmail || "unknown";
+ line1.appendChild(userB);
+ line1.appendChild(document.createTextNode(" · " + fmtTime(appeal.createdAt)));
+ row.appendChild(line1);
+
const line2 = document.createElement("div");
- line2.className = "muted small";
- line2.textContent = "Reporter: " + (r.reporterEmail || "unknown") + " · Reported: " + (r.reportedUserEmail || "unknown");
- reportRow.appendChild(line2);
-
- const line3 = document.createElement("div");
- line3.textContent = r.details || "No additional details.";
- reportRow.appendChild(line3);
-
- const line4 = document.createElement("div");
- line4.className = "muted small";
- line4.textContent = "DM " + (r.messageId ? "id " + r.messageId : r.groupMessageId ? "group id " + r.groupMessageId : "user report");
- reportRow.appendChild(line4);
-
- reportsList.appendChild(reportRow);
+ line2.textContent = appeal.reason;
+ row.appendChild(line2);
+
+ const btnDiv = document.createElement("div");
+ btnDiv.style.cssText = "margin-top:8px;display:flex;gap:8px;";
+ const reviewBtn = document.createElement("button");
+ reviewBtn.className = "btn primary";
+ reviewBtn.setAttribute("data-appeal-id", appeal.id);
+ reviewBtn.textContent = "Review";
+ reviewBtn.addEventListener("click", async () => {
+ try {
+ $("appeal-reason-text").textContent = appeal.reason;
+ const decision = await new Promise((resolve) => {
+ const m = $("appeal-review-modal");
+ const decisionSelect = $("appeal-decision");
+ const responseText = $("appeal-response");
+ const submitBtn = $("appeal-submit");
+ const cancelBtn = $("appeal-cancel");
+
+ decisionSelect.value = "";
+ responseText.value = "";
+
+ const close = (v) => {
+ m.classList.add("hidden");
+ submitBtn.removeEventListener("click", onSubmit);
+ cancelBtn.removeEventListener("click", onCancel);
+ resolve(v);
+ };
+ const onSubmit = () => {
+ if (!decisionSelect.value) {
+ decisionSelect.focus();
+ return;
+ }
+ close({ action: decisionSelect.value, reason: responseText.value || null });
+ };
+ const onCancel = () => close(null);
+
+ submitBtn.addEventListener("click", onSubmit);
+ cancelBtn.addEventListener("click", onCancel);
+ m.classList.remove("hidden");
+ decisionSelect.focus();
+ });
+
+ if (!decision) return;
+
+ await api.post(`/api/mod/appeals/${appeal.id}`, { action: decision.action, reason: decision.reason });
+ toast("Appeal reviewed", "info");
+ loadModeration();
+ } catch (e) {
+ toast(`Error: ${e.message}`, "error");
+ }
+ });
+ btnDiv.appendChild(reviewBtn);
+ row.appendChild(btnDiv);
+
+ appealsList.appendChild(row);
});
}
- const users = await api.get("/api/mod/users?limit=200");
- const usersList = $("mod-users-list");
- usersList.replaceChildren();
- if (users.length === 0) {
+ const auditLog = await api.get("/api/mod/audit-log?limit=50");
+ const auditList = $("mod-audit-log");
+ auditList.replaceChildren();
+ if (auditLog.entries.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
const p = document.createElement("p");
- p.textContent = "No user accounts.";
+ p.textContent = "No recent actions.";
empty.appendChild(p);
- usersList.appendChild(empty);
+ auditList.appendChild(empty);
} else {
- users.forEach(u => {
- const sessionItem = document.createElement("div");
- sessionItem.className = "session-item";
- sessionItem.style.cssText = "display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;";
-
- const leftDiv = document.createElement("div");
- leftDiv.style.cssText = "min-width:0;";
-
- const emailDiv = document.createElement("div");
- const emailB = document.createElement("b");
- emailB.textContent = u.email;
- emailDiv.appendChild(emailB);
- if (u.role === "moderator") {
- const hint = document.createElement("span");
- hint.className = "hint";
- hint.textContent = "(moderator)";
- emailDiv.appendChild(document.createTextNode(" "));
- emailDiv.appendChild(hint);
- }
- leftDiv.appendChild(emailDiv);
-
- const statusDiv = document.createElement("div");
- statusDiv.className = "muted small";
- statusDiv.textContent = "Status: " + u.status + " · Created " + fmtTime(u.createdAt) + " · Last login " + fmtTime(u.lastLoginAt);
- leftDiv.appendChild(statusDiv);
-
- sessionItem.appendChild(leftDiv);
-
- const rightDiv = document.createElement("div");
- rightDiv.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;";
-
- if (u.status !== "suspended" && u.status !== "banned") {
- const suspendBtn = document.createElement("button");
- suspendBtn.className = "btn ghost";
- suspendBtn.setAttribute("data-mod-action", "suspend");
- suspendBtn.setAttribute("data-user-id", u.id);
- suspendBtn.textContent = "Suspend";
- rightDiv.appendChild(suspendBtn);
-
- const banBtn = document.createElement("button");
- banBtn.className = "btn ghost";
- banBtn.setAttribute("data-mod-action", "ban");
- banBtn.setAttribute("data-user-id", u.id);
- banBtn.textContent = "Ban";
- rightDiv.appendChild(banBtn);
- }
-
- if (u.status !== "active") {
- const restoreBtn = document.createElement("button");
- restoreBtn.className = "btn primary";
- restoreBtn.setAttribute("data-mod-action", "restore");
- restoreBtn.setAttribute("data-user-id", u.id);
- restoreBtn.textContent = "Restore";
- rightDiv.appendChild(restoreBtn);
+ auditLog.entries.forEach(entry => {
+ const row = document.createElement("div");
+ row.className = "report-row";
+ row.style.cssText = "padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.08);";
+
+ const line1 = document.createElement("div");
+ const actionB = document.createElement("b");
+ actionB.textContent = entry.action;
+ line1.appendChild(actionB);
+ line1.appendChild(document.createTextNode(" · " + fmtTime(entry.createdAt)));
+ row.appendChild(line1);
+
+ const line2 = document.createElement("div");
+ line2.className = "muted small";
+ line2.textContent = "By: " + (entry.modUsername || "unknown") + " → Target: " + (entry.targetUsername || "unknown");
+ row.appendChild(line2);
+
+ if (entry.reason) {
+ const line3 = document.createElement("div");
+ line3.textContent = entry.reason;
+ row.appendChild(line3);
}
-
- sessionItem.appendChild(rightDiv);
- usersList.appendChild(sessionItem);
+
+ auditList.appendChild(row);
});
- for (const btn of usersList.querySelectorAll("[data-mod-action]")) {
- btn.addEventListener("click", async () => {
- try {
- const action = btn.dataset.modAction;
- const userId = Number(btn.dataset.userId);
- const confirmText = action === "ban" ? "Ban this account?" : action === "suspend" ? "Suspend this account?" : "Restore this account?";
- const ok = await confirmDialog({ title: confirmText, body: "This change affects sign-in and messaging rights.", okText: action === "restore" ? "Restore" : action === "ban" ? "Ban" : "Suspend", danger: action !== "restore" });
- if (!ok) return;
- await api.post(`/api/mod/users/${userId}/status`, { action });
- const actionLabel = { suspend: "suspended", ban: "banned", restore: "restored" }[action];
- toast(`Account ${actionLabel}`, "info");
- loadModeration();
- } catch (e) {
- toast(`Error: ${e.message}`, "error");
- }
- });
- }
}
} catch (e) {
const reportsList = $("mod-reports-list");
@@ -887,6 +1155,18 @@ async function loadModeration() {
errorEl2.className = "form-error";
errorEl2.textContent = e.message;
usersList.replaceChildren(errorEl2);
+
+ const appealsList = $("mod-appeals-list");
+ const errorEl3 = document.createElement("p");
+ errorEl3.className = "form-error";
+ errorEl3.textContent = e.message;
+ appealsList.replaceChildren(errorEl3);
+
+ const auditList = $("mod-audit-log");
+ const errorEl4 = document.createElement("p");
+ errorEl4.className = "form-error";
+ errorEl4.textContent = e.message;
+ auditList.replaceChildren(errorEl4);
}
async function loadSessions() {