From e53cc26a3b652a92201c8c35ebf9b89c9aca04d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 20:05:58 +0000 Subject: [PATCH 1/8] feat: implement audit logging system for moderator actions - Add mod_actions table with mod_id, target_id, action, reason, and created_at - Create indexes on mod_id, target_id, and created_at for efficient queries - Log all moderator actions (suspend, ban, restore, role changes) to audit table - Add GET /api/mod/audit-log endpoint to retrieve audit logs with pagination - Add audit log UI section to moderation dashboard - Display audit entries showing action, moderator, target, reason, and timestamp https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH --- backend/main.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 6 ++++ static/js/app.js | 44 +++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/backend/main.py b/backend/main.py index 7f55e9d..90d62fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -235,6 +235,17 @@ ); 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); """ @@ -1594,6 +1605,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 +1629,71 @@ 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 + } + + # ── Session management ──────────────────────────────────────────────────────────── @app.post("/api/auth/logout-all") diff --git a/static/index.html b/static/index.html index 0811c2f..3eb74a3 100644 --- a/static/index.html +++ b/static/index.html @@ -371,6 +371,12 @@

User accounts

Suspend or ban an account to stop abusive activity.

+ +
+

Audit log

+

All moderator actions are logged for transparency and accountability.

+
+
diff --git a/static/js/app.js b/static/js/app.js index 637d80b..a9aa1b0 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -874,6 +874,44 @@ async function loadModeration() { }); } } + + 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 recent actions."; + empty.appendChild(p); + auditList.appendChild(empty); + } else { + 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); + } + + auditList.appendChild(row); + }); + } } catch (e) { const reportsList = $("mod-reports-list"); const usersList = $("mod-users-list"); @@ -887,6 +925,12 @@ async function loadModeration() { errorEl2.className = "form-error"; errorEl2.textContent = e.message; usersList.replaceChildren(errorEl2); + + const auditList = $("mod-audit-log"); + const errorEl3 = document.createElement("p"); + errorEl3.className = "form-error"; + errorEl3.textContent = e.message; + auditList.replaceChildren(errorEl3); } async function loadSessions() { From e4e87d0fce6843110c06350b5c9e6073fe11879a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 20:06:41 +0000 Subject: [PATCH 2/8] feat: implement suspension tiers with duration selection - Add suspension duration modal with predefined options (1, 7, 30, 60 days) - Add optional reason field when suspending accounts - Update moderator action handler to use suspensionDialog for suspension actions - Store suspension duration and reason in database for audit trail - Allow moderators to specify why account is being suspended https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH --- static/index.html | 26 +++++++++++++++++++++++ static/js/app.js | 53 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/static/index.html b/static/index.html index 3eb74a3..d83be86 100644 --- a/static/index.html +++ b/static/index.html @@ -507,6 +507,32 @@

Start something new

+ + + + + + + +