"""FlowDeck — Admin API: users, roles, stats, audit.""" from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi.responses import JSONResponse router = APIRouter(tags=["admin"], prefix="/api/admin") # ── Dependency ── async def admin_required(request: Request): from app.auth.session import get_current_user user = get_current_user(request) if not user: raise HTTPException(status_code=403, detail="Admin access required") # Also check DB directly (session cookie may be stale) if not user.get("is_admin"): from app.db import get_conn with get_conn() as conn: row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone() if not row or not row["is_admin"]: raise HTTPException(status_code=403, detail="Admin access required") return user # ── Users ── @router.get("/users") def list_users(_admin=Depends(admin_required)): """List all users with workspace/file/folder counts and storage usage.""" from app.db import get_conn with get_conn() as conn: rows = conn.execute(""" SELECT u.id, u.login, u.full_name, u.email, u.is_admin, u.is_active, u.last_login, u.created_at, (SELECT COUNT(*) FROM workspaces WHERE owner_id=u.id) AS ws_count, (SELECT COUNT(*) FROM pages WHERE workspace_id IN (SELECT id FROM workspaces WHERE owner_id=u.id)) AS page_count FROM users u ORDER BY u.id """).fetchall() users = [] for r in rows: d = dict(r) d["file_count"] = d["page_count"] d["folder_count"] = 0 d["total_mb"] = 0 users.append(d) return {"users": users} @router.post("/users") def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})): """Create a new user (admin only).""" from app.db import get_conn from app.password_utils import hash_password login = body.get("login", "").strip() name = body.get("name", login) email = body.get("email", login) password = body.get("password", "").strip() is_admin = int(body.get("is_admin", 0)) if not login or not password: return JSONResponse({"error": "Login and password required"}, status_code=400) if len(password) < 6: return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) with get_conn() as conn: existing = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone() if existing: return JSONResponse({"error": "User already exists"}, status_code=409) conn.execute( "INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)", (login, name, email, hash_password(password), is_admin), ) conn.commit() uid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] return {"status": "ok", "user": {"id": uid, "login": login}} @router.put("/users/{user_id:int}") def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})): """Update a user: name, email, password, admin status, active status.""" from app.db import get_conn from app.password_utils import hash_password with get_conn() as conn: user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone() if not user: return JSONResponse({"error": "User not found"}, status_code=404) if "name" in body: conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["name"], user_id)) if "email" in body: conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"], user_id)) if "password" in body and body["password"].strip(): pw = body["password"].strip() if len(pw) < 6: return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), user_id)) if "is_admin" in body: conn.execute("UPDATE users SET is_admin=? WHERE id=?", (int(body["is_admin"]), user_id)) if "is_active" in body: conn.execute("UPDATE users SET is_active=? WHERE id=?", (int(body["is_active"]), user_id)) conn.commit() return {"status": "ok"} @router.delete("/users/{user_id:int}") def delete_user(user_id: int, _admin=Depends(admin_required)): """Delete a user and cascade their data.""" from app.db import get_conn with get_conn() as conn: user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone() if not user: return JSONResponse({"error": "User not found"}, status_code=404) # Cascade delete: first delete child records conn.execute("DELETE FROM login_history WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (user_id,)) conn.execute("DELETE FROM workspace_members WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM comments WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM page_history WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM favorites WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM gitea_private_pages WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM tags WHERE user_id=?", (user_id,)) # Delete workspaces owned by this user ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall() for ws in ws_rows: conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],)) conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],)) conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,)) conn.execute("DELETE FROM users WHERE id=?", (user_id,)) conn.commit() return {"status": "ok"} # ── Stats ── @router.get("/stats") def user_stats(_admin=Depends(admin_required)): """Aggregate stats: total users, workspaces, files, storage.""" from app.db import get_conn with get_conn() as conn: total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] total_ws = conn.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0] total_files = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0] total_folders = 0 total_bytes = 0 return { "total_users": total_users, "total_workspaces": total_ws, "total_files": total_files, "total_folders": total_folders, "total_mb": round(total_bytes / (1024 * 1024), 2), } # ── Audit ── @router.get("/audit") def audit_log(limit: int = 100, _admin=Depends(admin_required)): """Recent login history.""" from app.db import get_conn with get_conn() as conn: rows = conn.execute(""" SELECT lh.id, lh.user_id, u.login, u.full_name, lh.ip_address, lh.user_agent, lh.logged_at FROM login_history lh JOIN users u ON u.id = lh.user_id ORDER BY lh.logged_at DESC LIMIT ? """, (min(limit, 500),)).fetchall() return {"entries": [dict(r) for r in rows]}