diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-11 11:50:08 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-11 11:50:08 +0200 |
| commit | c8f2de4025ea67b579e66cf608f08a8d35ee4a3c (patch) | |
| tree | ae1261441af3ca372ce0e89c0000c5c1616dfc8d /packages/meshbay-hub/src/meshbay_hub/api/admin.py | |
| parent | 441ef090055ff4f8c47e78826e0a992f45d918dc (diff) | |
| download | meshbay-c8f2de4025ea67b579e66cf608f08a8d35ee4a3c.tar.gz | |
feat(hub): Phase 10.1–10.4 — Site overlay + admin/moderation UI
- Site overlay: landing page, /about, /downloads (dark/light, responsive)
- User role column (user/moderator/admin) with config-based admin sync
- require_moderator dependency + admin API (8 endpoints: stats, users,
groups, audit logs)
- Admin SPA panel at #/admin with 5 tabs (stats, users, groups, logs,
blocklist) — visible only to moderators/admins
- SPA also served at /app/ for Caddy site overlay integration
- GET /v1/users/me returns current user role
- 15 new tests, 147 total passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/admin.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 273 |
1 files changed, 273 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py new file mode 100644 index 0000000..c3fa8c2 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -0,0 +1,273 @@ +""" +MeshBay Hub — admin/moderation panel endpoints. + +All endpoints require moderator or admin role. +Separate from moderation.py (which handles public reporting and content blocklist). +""" + +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub.auth import decrypt_email +from meshbay_hub.api.deps import require_moderator +from meshbay_hub.api.revocation import get_connected_node_count +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1/admin", tags=["admin"]) + + +# ── Request models ─────────────────────────────────────────────────────────── + +class UserPatchRequest(BaseModel): + role: str | None = None + status: str | None = None + + +class GroupPatchRequest(BaseModel): + status: str | None = None + + +# ── Stats ──────────────────────────────────────────────────────────────────── + +@router.get("/stats") +async def admin_stats( + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), +): + user_count = (await db.execute(select(func.count()).select_from(User))).scalar_one() + group_count = (await db.execute(select(func.count()).select_from(Group))).scalar_one() + node_count = (await db.execute(select(func.count()).select_from(Node))).scalar_one() + return { + "users": user_count, + "groups": group_count, + "nodes": node_count, + "online_nodes": get_connected_node_count(), + } + + +# ── Users ──────────────────────────────────────────────────────────────────── + +@router.get("/users") +async def admin_list_users( + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), + q: str = "", + offset: int = 0, + limit: int = Query(default=50, le=200), +): + query = select(User).order_by(User.created_at.desc()) + if q: + query = query.where(User.username.ilike(f"%{q}%")) + query = query.offset(offset).limit(limit) + result = await db.execute(query) + users = result.scalars().all() + + total_query = select(func.count()).select_from(User) + if q: + total_query = total_query.where(User.username.ilike(f"%{q}%")) + total = (await db.execute(total_query)).scalar_one() + + return { + "users": [ + { + "id": u.id, + "username": u.username, + "role": u.role, + "status": u.status, + "created_at": u.created_at.isoformat(), + } + for u in users + ], + "total": total, + } + + +@router.get("/users/{user_id}") +async def admin_get_user( + user_id: str, + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), +): + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + group_count = (await db.execute( + select(func.count()).where(GroupMember.user_id == user_id) + )).scalar_one() + + try: + email = decrypt_email(user.email) + except Exception: + email = "(encrypted)" + + return { + "id": user.id, + "username": user.username, + "email": email, + "role": user.role, + "status": user.status, + "created_at": user.created_at.isoformat(), + "group_count": group_count, + } + + +@router.patch("/users/{user_id}") +async def admin_patch_user( + user_id: str, + body: UserPatchRequest, + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), +): + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot modify your own account") + + if body.role is not None: + if body.role not in ("user", "moderator", "admin"): + raise HTTPException(status_code=422, detail="role must be user, moderator, or admin") + user.role = body.role + log.info("User %s role changed to %s by %s", user.username, body.role, current_user.username) + + if body.status is not None: + if body.status not in ("active", "suspended", "revoked"): + raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked") + user.status = body.status + log.info("User %s status changed to %s by %s", + user.username, body.status, current_user.username) + + db.add(IPLog( + user_id=current_user.id, + event="admin_user_update", + ip_address="admin", + detail=f"{user.username}: role={user.role} status={user.status}", + )) + await db.commit() + + return { + "id": user.id, + "username": user.username, + "role": user.role, + "status": user.status, + } + + +# ── Groups ─────────────────────────────────────────────────────────────────── + +@router.get("/groups") +async def admin_list_groups( + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), + offset: int = 0, + limit: int = Query(default=50, le=200), +): + query = ( + select( + Group, + func.count(GroupMember.user_id).label("member_count"), + ) + .outerjoin(GroupMember, Group.id == GroupMember.group_id) + .group_by(Group.id) + .order_by(Group.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(query) + rows = result.all() + + total = (await db.execute(select(func.count()).select_from(Group))).scalar_one() + + return { + "groups": [ + { + "id": g.id, + "name": g.name, + "admin_id": g.admin_id, + "visibility": g.visibility, + "status": g.status, + "created_at": g.created_at.isoformat(), + "member_count": mc, + } + for g, mc in rows + ], + "total": total, + } + + +@router.patch("/groups/{group_id}") +async def admin_patch_group( + group_id: str, + body: GroupPatchRequest, + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), +): + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + + if body.status is not None: + if body.status not in ("active", "suspended", "revoked"): + raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked") + group.status = body.status + log.info("Group %s status changed to %s by %s", + group.name, body.status, current_user.username) + + db.add(IPLog( + user_id=current_user.id, + event="admin_group_update", + ip_address="admin", + detail=f"{group.name}: status={group.status}", + )) + await db.commit() + + return { + "id": group.id, + "name": group.name, + "status": group.status, + } + + +# ── IP Audit Logs ──────────────────────────────────────────────────────────── + +@router.get("/logs") +async def admin_list_logs( + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), + user_id: str | None = None, + event: str | None = None, + offset: int = 0, + limit: int = Query(default=50, le=200), +): + query = select(IPLog).order_by(IPLog.timestamp.desc()) + if user_id: + query = query.where(IPLog.user_id == user_id) + if event: + query = query.where(IPLog.event == event) + query = query.offset(offset).limit(limit) + result = await db.execute(query) + logs = result.scalars().all() + + return { + "logs": [ + { + "id": lg.id, + "user_id": lg.user_id, + "event": lg.event, + "ip_address": lg.ip_address, + "detail": lg.detail, + "timestamp": lg.timestamp.isoformat(), + } + for lg in logs + ], + } |