diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 00000000..1423810c --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,720 @@ +"""Admin dashboard API — management endpoints for bounties, contributors, +reviews, financials, system health, and audit log. + +Authentication: Bearer token must match the ADMIN_API_KEY environment variable. +All mutating endpoints write a structured entry to the in-process audit store +so the /audit-log endpoint can surface them immediately without a DB round-trip. + +Environment variables: + ADMIN_API_KEY Required. The shared secret for admin access. +""" + +import os +import time +from collections import deque +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, Field + +from app.core.audit import audit_event +from app.services.bounty_service import _bounty_store +from app.services.contributor_service import _store as _contributor_store +from app.models.bounty import BountyStatus +from app.constants import START_TIME + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "") +_security = HTTPBearer(auto_error=False) + +# In-process audit ring buffer (last 1 000 admin actions) +_audit_log: deque[Dict[str, Any]] = deque(maxlen=1_000) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +# --------------------------------------------------------------------------- +# Auth dependency +# --------------------------------------------------------------------------- + + +async def require_admin( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_security), +) -> str: + """Verify the caller holds a valid admin API key. + + Returns the string ``"admin"`` on success so callers can use it as an + actor ID in audit entries. + + Raises: + HTTPException 401: No credentials supplied. + HTTPException 403: Credentials present but invalid. + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Admin authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not ADMIN_API_KEY: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Admin authentication is not configured on this server", + ) + if credentials.credentials != ADMIN_API_KEY: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid admin credentials", + ) + return "admin" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _log(event: str, actor: str = "admin", **details: Any) -> None: + """Append an entry to the in-process audit log and structlog stream.""" + entry: Dict[str, Any] = { + "event": event, + "actor": actor, + "timestamp": datetime.now(timezone.utc).isoformat(), + **details, + } + _audit_log.appendleft(entry) + audit_event(event, actor=actor, **details) + + +def _bounty_to_dict(b: Any) -> Dict[str, Any]: + """Serialise a BountyDB to a JSON-safe dict for admin responses.""" + return { + "id": b.id, + "title": b.title, + "status": b.status, + "tier": b.tier, + "reward_amount": b.reward_amount, + "created_by": b.created_by, + "deadline": b.deadline.isoformat() if hasattr(b.deadline, "isoformat") else str(b.deadline), + "submission_count": len(b.submissions) if b.submissions else 0, + "created_at": b.created_at.isoformat() if hasattr(b.created_at, "isoformat") else str(b.created_at), + } + + +def _contributor_to_dict(c: Any) -> Dict[str, Any]: + """Serialise a contributor to a JSON-safe dict.""" + return { + "id": c.id, + "username": c.username, + "display_name": getattr(c, "display_name", c.username), + "tier": getattr(c, "current_tier", "T1"), + "reputation_score": getattr(c, "reputation_score", 0.0), + "total_bounties_completed": getattr(c, "total_bounties_completed", 0), + "total_earnings": float(getattr(c, "total_earnings", 0)), + "is_banned": getattr(c, "is_banned", False), + "skills": getattr(c, "skills", []), + "created_at": (c.created_at.isoformat() + if hasattr(c, "created_at") and c.created_at and hasattr(c.created_at, "isoformat") + else str(getattr(c, "created_at", ""))), + } + + +# --------------------------------------------------------------------------- +# Response models +# --------------------------------------------------------------------------- + + +class AdminOverview(BaseModel): + """Aggregate platform statistics for the admin overview panel.""" + + total_bounties: int + open_bounties: int + completed_bounties: int + cancelled_bounties: int + total_contributors: int + active_contributors: int + banned_contributors: int + total_fndry_paid: float + total_submissions: int + pending_reviews: int + uptime_seconds: int + timestamp: str + + +class BountyAdminItem(BaseModel): + id: str + title: str + status: str + tier: Any + reward_amount: float + created_by: str + deadline: str + submission_count: int + created_at: str + + +class BountyListAdminResponse(BaseModel): + items: List[BountyAdminItem] + total: int + page: int + per_page: int + + +class BountyAdminUpdate(BaseModel): + """Fields an admin can update on a bounty.""" + + status: Optional[str] = Field(None, description="New lifecycle status") + reward_amount: Optional[float] = Field(None, gt=0, description="Adjusted reward") + title: Optional[str] = Field(None, min_length=3, max_length=200) + + +class ContributorAdminItem(BaseModel): + id: str + username: str + display_name: str + tier: str + reputation_score: float + total_bounties_completed: int + total_earnings: float + is_banned: bool + skills: List[str] + created_at: str + + +class ContributorListAdminResponse(BaseModel): + items: List[ContributorAdminItem] + total: int + page: int + per_page: int + + +class BanRequest(BaseModel): + reason: str = Field(..., min_length=5, max_length=500, description="Reason for the ban") + + +class ReviewPipelineItem(BaseModel): + bounty_id: str + bounty_title: str + submission_id: str + pr_url: str + submitted_by: str + ai_score: float + review_complete: bool + meets_threshold: bool + submitted_at: str + + +class ReviewPipelineResponse(BaseModel): + active: List[ReviewPipelineItem] + total_active: int + pass_rate: float = Field(description="Fraction of completed reviews that meet threshold") + avg_score: float + + +class FinancialOverview(BaseModel): + total_fndry_distributed: float + total_paid_bounties: int + pending_payout_count: int + pending_payout_amount: float + avg_reward: float + highest_reward: float + + +class PayoutHistoryItem(BaseModel): + bounty_id: str + bounty_title: str + winner: str + amount: float + status: str + completed_at: Optional[str] + + +class PayoutHistoryResponse(BaseModel): + items: List[PayoutHistoryItem] + total: int + + +class SystemHealthResponse(BaseModel): + status: str + uptime_seconds: int + timestamp: str + services: Dict[str, str] + queue_depth: int + webhook_events_processed: int + active_websocket_connections: int + + +class AuditLogEntry(BaseModel): + event: str + actor: str + timestamp: str + details: Dict[str, Any] = Field(default_factory=dict) + + +class AuditLogResponse(BaseModel): + entries: List[AuditLogEntry] + total: int + + +# --------------------------------------------------------------------------- +# Overview +# --------------------------------------------------------------------------- + + +@router.get( + "/overview", + response_model=AdminOverview, + summary="Platform overview statistics", +) +async def get_overview(_: str = Depends(require_admin)) -> AdminOverview: + """Return aggregate statistics used by the admin overview panel.""" + bounties = list(_bounty_store.values()) + contributors = list(_contributor_store.values()) + + total_fndry = sum( + b.reward_amount for b in bounties if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED) + ) + total_submissions = sum( + len(b.submissions) for b in bounties if b.submissions + ) + pending_reviews = sum( + 1 for b in bounties + for s in (b.submissions or []) + if getattr(s, "review_complete", False) is False + and s.status == "pending" + ) + + return AdminOverview( + total_bounties=len(bounties), + open_bounties=sum(1 for b in bounties if b.status == BountyStatus.OPEN), + completed_bounties=sum(1 for b in bounties if b.status in (BountyStatus.COMPLETED, BountyStatus.PAID)), + cancelled_bounties=sum(1 for b in bounties if b.status == BountyStatus.CANCELLED), + total_contributors=len(contributors), + active_contributors=sum(1 for c in contributors if not getattr(c, "is_banned", False)), + banned_contributors=sum(1 for c in contributors if getattr(c, "is_banned", False)), + total_fndry_paid=total_fndry, + total_submissions=total_submissions, + pending_reviews=pending_reviews, + uptime_seconds=round(time.monotonic() - START_TIME), + timestamp=datetime.now(timezone.utc).isoformat(), + ) + + +# --------------------------------------------------------------------------- +# Bounty management +# --------------------------------------------------------------------------- + + +@router.get( + "/bounties", + response_model=BountyListAdminResponse, + summary="List all bounties with admin-level detail", +) +async def list_bounties_admin( + search: Optional[str] = Query(None, description="Filter by title substring"), + status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"), + tier: Optional[int] = Query(None, description="Filter by tier (1, 2, 3)"), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + _: str = Depends(require_admin), +) -> BountyListAdminResponse: + """Return paginated bounty list with optional search and status filter.""" + items = list(_bounty_store.values()) + + if search: + q = search.lower() + items = [b for b in items if q in b.title.lower() or q in getattr(b, "description", "").lower()] + if status_filter: + items = [b for b in items if b.status == status_filter] + if tier is not None: + items = [b for b in items if b.tier == tier] + + # Sort newest first + items.sort(key=lambda b: getattr(b, "created_at", datetime.min), reverse=True) + + total = len(items) + offset = (page - 1) * per_page + page_items = items[offset: offset + per_page] + + return BountyListAdminResponse( + items=[BountyAdminItem(**_bounty_to_dict(b)) for b in page_items], + total=total, + page=page, + per_page=per_page, + ) + + +@router.patch( + "/bounties/{bounty_id}", + summary="Update a bounty (status, reward, title)", +) +async def update_bounty_admin( + bounty_id: str, + update: BountyAdminUpdate, + actor: str = Depends(require_admin), +) -> Dict[str, Any]: + """Allow an admin to patch a bounty's status, reward, or title.""" + bounty = _bounty_store.get(bounty_id) + if not bounty: + raise HTTPException(status_code=404, detail=f"Bounty {bounty_id!r} not found") + + changes: Dict[str, Any] = {} + + if update.status is not None: + bounty.status = update.status + changes["status"] = update.status + + if update.reward_amount is not None: + old_reward = bounty.reward_amount + bounty.reward_amount = update.reward_amount + changes["reward_amount"] = {"from": old_reward, "to": update.reward_amount} + + if update.title is not None: + bounty.title = update.title + changes["title"] = update.title + + if not changes: + raise HTTPException(status_code=400, detail="No changes provided") + + _log("admin_bounty_updated", actor=actor, bounty_id=bounty_id, changes=changes) + return {"ok": True, "bounty_id": bounty_id, "changes": changes} + + +@router.post( + "/bounties/{bounty_id}/close", + summary="Force-close a bounty", +) +async def close_bounty_admin( + bounty_id: str, + actor: str = Depends(require_admin), +) -> Dict[str, str]: + """Set a bounty to CANCELLED regardless of its current lifecycle state.""" + bounty = _bounty_store.get(bounty_id) + if not bounty: + raise HTTPException(status_code=404, detail=f"Bounty {bounty_id!r} not found") + + old_status = bounty.status + bounty.status = BountyStatus.CANCELLED + _log("admin_bounty_closed", actor=actor, bounty_id=bounty_id, previous_status=str(old_status)) + return {"ok": "true", "bounty_id": bounty_id, "status": BountyStatus.CANCELLED} + + +# --------------------------------------------------------------------------- +# Contributor management +# --------------------------------------------------------------------------- + + +@router.get( + "/contributors", + response_model=ContributorListAdminResponse, + summary="List all contributors", +) +async def list_contributors_admin( + search: Optional[str] = Query(None, description="Filter by username"), + is_banned: Optional[bool] = Query(None, description="Filter by ban status"), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + _: str = Depends(require_admin), +) -> ContributorListAdminResponse: + """Return paginated contributors with admin-level fields.""" + items = list(_contributor_store.values()) + + if search: + q = search.lower() + items = [c for c in items if q in c.username.lower()] + if is_banned is not None: + items = [c for c in items if getattr(c, "is_banned", False) == is_banned] + + items.sort(key=lambda c: getattr(c, "reputation_score", 0.0), reverse=True) + + total = len(items) + offset = (page - 1) * per_page + page_items = items[offset: offset + per_page] + + return ContributorListAdminResponse( + items=[ContributorAdminItem(**_contributor_to_dict(c)) for c in page_items], + total=total, + page=page, + per_page=per_page, + ) + + +@router.post( + "/contributors/{contributor_id}/ban", + summary="Ban a contributor", +) +async def ban_contributor( + contributor_id: str, + body: BanRequest, + actor: str = Depends(require_admin), +) -> Dict[str, str]: + """Set `is_banned = True` on a contributor profile.""" + contributor = _contributor_store.get(contributor_id) + if not contributor: + raise HTTPException(status_code=404, detail=f"Contributor {contributor_id!r} not found") + + contributor.is_banned = True + _log( + "admin_contributor_banned", + actor=actor, + contributor_id=contributor_id, + username=contributor.username, + reason=body.reason, + ) + return {"ok": "true", "contributor_id": contributor_id, "action": "banned"} + + +@router.post( + "/contributors/{contributor_id}/unban", + summary="Unban a contributor", +) +async def unban_contributor( + contributor_id: str, + actor: str = Depends(require_admin), +) -> Dict[str, str]: + """Clear the `is_banned` flag on a contributor profile.""" + contributor = _contributor_store.get(contributor_id) + if not contributor: + raise HTTPException(status_code=404, detail=f"Contributor {contributor_id!r} not found") + + contributor.is_banned = False + _log( + "admin_contributor_unbanned", + actor=actor, + contributor_id=contributor_id, + username=contributor.username, + ) + return {"ok": "true", "contributor_id": contributor_id, "action": "unbanned"} + + +# --------------------------------------------------------------------------- +# Review pipeline +# --------------------------------------------------------------------------- + + +@router.get( + "/reviews/pipeline", + response_model=ReviewPipelineResponse, + summary="Review pipeline — active and aggregate metrics", +) +async def get_review_pipeline(_: str = Depends(require_admin)) -> ReviewPipelineResponse: + """Return active (incomplete) reviews with pass-rate and average score.""" + active: List[ReviewPipelineItem] = [] + completed_count = 0 + passing_count = 0 + score_sum = 0.0 + + for bounty in _bounty_store.values(): + for sub in (bounty.submissions or []): + ai_score = getattr(sub, "ai_score", 0.0) or 0.0 + review_complete = getattr(sub, "review_complete", False) + meets = getattr(sub, "meets_threshold", False) + + if review_complete: + completed_count += 1 + score_sum += ai_score + if meets: + passing_count += 1 + else: + active.append(ReviewPipelineItem( + bounty_id=bounty.id, + bounty_title=bounty.title, + submission_id=str(getattr(sub, "id", "")), + pr_url=getattr(sub, "pr_url", ""), + submitted_by=getattr(sub, "submitted_by", ""), + ai_score=ai_score, + review_complete=review_complete, + meets_threshold=meets, + submitted_at=( + sub.submitted_at.isoformat() + if hasattr(sub, "submitted_at") and hasattr(sub.submitted_at, "isoformat") + else str(getattr(sub, "submitted_at", "")) + ), + )) + + pass_rate = (passing_count / completed_count) if completed_count else 0.0 + avg_score = (score_sum / completed_count) if completed_count else 0.0 + + return ReviewPipelineResponse( + active=active, + total_active=len(active), + pass_rate=round(pass_rate, 4), + avg_score=round(avg_score, 2), + ) + + +# --------------------------------------------------------------------------- +# Financial overview +# --------------------------------------------------------------------------- + + +@router.get( + "/financial/overview", + response_model=FinancialOverview, + summary="Token distribution and payout summary", +) +async def get_financial_overview(_: str = Depends(require_admin)) -> FinancialOverview: + """Return aggregate financial metrics across all bounties.""" + bounties = list(_bounty_store.values()) + paid = [b for b in bounties if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED)] + pending = [b for b in bounties if b.status in (BountyStatus.UNDER_REVIEW, BountyStatus.COMPLETED)] + + total_distributed = sum(b.reward_amount for b in paid) + rewards = [b.reward_amount for b in bounties if b.reward_amount] + avg_reward = (sum(rewards) / len(rewards)) if rewards else 0.0 + highest = max(rewards) if rewards else 0.0 + pending_amount = sum(b.reward_amount for b in pending) + + return FinancialOverview( + total_fndry_distributed=total_distributed, + total_paid_bounties=len(paid), + pending_payout_count=len(pending), + pending_payout_amount=pending_amount, + avg_reward=round(avg_reward, 2), + highest_reward=highest, + ) + + +@router.get( + "/financial/payouts", + response_model=PayoutHistoryResponse, + summary="Payout history", +) +async def get_payout_history( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + _: str = Depends(require_admin), +) -> PayoutHistoryResponse: + """Return bounties that have completed payouts, newest first.""" + paid_bounties = [ + b for b in _bounty_store.values() + if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED) + ] + paid_bounties.sort( + key=lambda b: getattr(b, "created_at", datetime.min), reverse=True + ) + + total = len(paid_bounties) + offset = (page - 1) * per_page + page_items = paid_bounties[offset: offset + per_page] + + items = [ + PayoutHistoryItem( + bounty_id=b.id, + bounty_title=b.title, + winner=getattr(b, "winner_wallet", "") or getattr(b, "created_by", ""), + amount=b.reward_amount, + status=b.status, + completed_at=( + b.created_at.isoformat() + if hasattr(b.created_at, "isoformat") else str(b.created_at) + ), + ) + for b in page_items + ] + + return PayoutHistoryResponse(items=items, total=total) + + +# --------------------------------------------------------------------------- +# System health (enhanced) +# --------------------------------------------------------------------------- + + +@router.get( + "/system/health", + response_model=SystemHealthResponse, + summary="Enhanced system health for admin dashboard", +) +async def get_system_health_admin(_: str = Depends(require_admin)) -> SystemHealthResponse: + """Return service status, uptime, queue depth, and WS connections.""" + from app.database import engine + from sqlalchemy import text + from sqlalchemy.exc import SQLAlchemyError + import os as _os + from redis.asyncio import from_url as redis_from_url, RedisError + + # DB probe + try: + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + db_status = "connected" + except (SQLAlchemyError, Exception): + db_status = "disconnected" + + # Redis probe + try: + redis_url = _os.getenv("REDIS_URL", "redis://localhost:6379/0") + client = redis_from_url(redis_url, decode_responses=True) + async with client: + await client.ping() + redis_status = "connected" + except (RedisError, Exception): + redis_status = "disconnected" + + # WebSocket connection count + try: + from app.services.websocket_manager import manager as ws_manager + ws_count = len(getattr(ws_manager, "active_connections", {})) + except Exception: + ws_count = 0 + + # Review queue depth (pending reviews) + pending_reviews = sum( + 1 for b in _bounty_store.values() + for s in (b.submissions or []) + if not getattr(s, "review_complete", False) and s.status == "pending" + ) + + return SystemHealthResponse( + status="healthy" if db_status == "connected" else "degraded", + uptime_seconds=round(time.monotonic() - START_TIME), + timestamp=datetime.now(timezone.utc).isoformat(), + services={"database": db_status, "redis": redis_status}, + queue_depth=pending_reviews, + webhook_events_processed=len(_audit_log), + active_websocket_connections=ws_count, + ) + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- + + +@router.get( + "/audit-log", + response_model=AuditLogResponse, + summary="Admin action audit log", +) +async def get_audit_log( + limit: int = Query(50, ge=1, le=200), + event_filter: Optional[str] = Query(None, alias="event", description="Filter by event name"), + _: str = Depends(require_admin), +) -> AuditLogResponse: + """Return recent admin audit log entries, newest first.""" + entries = list(_audit_log) + + if event_filter: + entries = [e for e in entries if event_filter in e.get("event", "")] + + total = len(entries) + entries = entries[:limit] + + return AuditLogResponse( + entries=[ + AuditLogEntry( + event=e.get("event", ""), + actor=e.get("actor", "admin"), + timestamp=e.get("timestamp", ""), + details={k: v for k, v in e.items() if k not in ("event", "actor", "timestamp")}, + ) + for e in entries + ], + total=total, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 12d4e06f..426a32b4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -49,6 +49,7 @@ from app.api.disputes import router as disputes_router from app.api.stats import router as stats_router from app.api.escrow import router as escrow_router +from app.api.admin import router as admin_router from app.database import init_db, close_db, engine from app.middleware.security import SecurityHeadersMiddleware from app.middleware.sanitization import InputSanitizationMiddleware @@ -371,6 +372,9 @@ async def value_error_handler(request: Request, exc: ValueError): # System Health: /health app.include_router(health_router) +# Admin Dashboard: /api/admin/* (protected by ADMIN_API_KEY) +app.include_router(admin_router) + @app.post("/api/sync", tags=["admin"]) async def trigger_sync(): diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 00000000..2d6c6fc0 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,376 @@ +"""Tests for the admin dashboard API (/api/admin/*). + +All tests run against an in-memory SQLite database with AUTH_ENABLED=false +so the normal auth middleware is a no-op. Admin auth is tested separately +by supplying / omitting the ADMIN_API_KEY Bearer token. +""" + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-ci") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import app.api.admin as admin_module +from app.api.admin import router as admin_router +from app.models.bounty import BountyDB, BountyStatus, BountyTier, SubmissionRecord +from app.services import bounty_service, contributor_service + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +TEST_API_KEY = "test-admin-key-abc" +AUTH_HEADER = {"Authorization": f"Bearer {TEST_API_KEY}"} +BAD_AUTH = {"Authorization": "Bearer wrong-key"} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def patch_admin_key(monkeypatch): + """Inject test API key into the admin module before each test.""" + monkeypatch.setattr(admin_module, "ADMIN_API_KEY", TEST_API_KEY) + + +@pytest.fixture() +def client(): + """Create a fresh TestClient with only the admin router mounted.""" + app = FastAPI() + app.include_router(admin_router) + return TestClient(app, raise_server_exceptions=True) + + +@pytest.fixture(autouse=True) +def clear_stores(): + """Reset in-memory stores and audit log between tests.""" + bounty_service._bounty_store.clear() + contributor_service._store.clear() + admin_module._audit_log.clear() + yield + bounty_service._bounty_store.clear() + contributor_service._store.clear() + admin_module._audit_log.clear() + + +def _make_bounty(bid="b1", title="Fix bug", status=BountyStatus.OPEN, reward=500.0): + """Insert a minimal BountyDB into the in-memory store.""" + from datetime import datetime, timezone, timedelta + bounty = BountyDB( + id=bid, + title=title, + description="A test bounty", + tier=BountyTier.T1, + required_skills=[], + reward_amount=reward, + created_by="creator-1", + deadline=(datetime.now(timezone.utc) + timedelta(days=7)).isoformat(), + status=status, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + bounty_service._bounty_store[bid] = bounty + return bounty + + +def _make_contributor(cid="c1", username="alice", banned=False): + """Insert a minimal contributor into the in-memory store.""" + from app.models.contributor import ContributorDB + c = ContributorDB( + id=cid, + username=username, + display_name=username.capitalize(), + skills=[], + badges=[], + reputation_score=10.0, + total_bounties_completed=2, + total_earnings=1000.0, + ) + c.is_banned = banned + contributor_service._store[cid] = c + return c + + +# --------------------------------------------------------------------------- +# Auth tests +# --------------------------------------------------------------------------- + +class TestAdminAuth: + def test_unauthenticated_request_returns_401(self, client): + resp = client.get("/api/admin/overview") + assert resp.status_code == 401 + + def test_wrong_key_returns_403(self, client): + resp = client.get("/api/admin/overview", headers=BAD_AUTH) + assert resp.status_code == 403 + + def test_no_api_key_configured_returns_503(self, client, monkeypatch): + monkeypatch.setattr(admin_module, "ADMIN_API_KEY", "") + resp = client.get("/api/admin/overview", headers=AUTH_HEADER) + assert resp.status_code == 503 + + def test_correct_key_allows_access(self, client): + resp = client.get("/api/admin/overview", headers=AUTH_HEADER) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Overview +# --------------------------------------------------------------------------- + +class TestOverview: + def test_returns_zero_counts_on_empty_stores(self, client): + data = client.get("/api/admin/overview", headers=AUTH_HEADER).json() + assert data["total_bounties"] == 0 + assert data["total_contributors"] == 0 + assert data["total_fndry_paid"] == 0 + + def test_counts_open_and_completed_bounties(self, client): + _make_bounty("b1", status=BountyStatus.OPEN) + _make_bounty("b2", status=BountyStatus.COMPLETED, reward=1000.0) + _make_bounty("b3", status=BountyStatus.PAID, reward=500.0) + + data = client.get("/api/admin/overview", headers=AUTH_HEADER).json() + assert data["total_bounties"] == 3 + assert data["open_bounties"] == 1 + assert data["completed_bounties"] == 2 + assert data["total_fndry_paid"] == 1500.0 + + def test_counts_banned_contributors(self, client): + _make_contributor("c1", "alice", banned=False) + _make_contributor("c2", "bob", banned=True) + + data = client.get("/api/admin/overview", headers=AUTH_HEADER).json() + assert data["total_contributors"] == 2 + assert data["active_contributors"] == 1 + assert data["banned_contributors"] == 1 + + +# --------------------------------------------------------------------------- +# Bounty management +# --------------------------------------------------------------------------- + +class TestBountyManagement: + def test_list_bounties_empty(self, client): + data = client.get("/api/admin/bounties", headers=AUTH_HEADER).json() + assert data["total"] == 0 + assert data["items"] == [] + + def test_list_bounties_pagination(self, client): + for i in range(5): + _make_bounty(f"b{i}", title=f"Bounty {i}") + + data = client.get("/api/admin/bounties?page=1&per_page=3", headers=AUTH_HEADER).json() + assert len(data["items"]) == 3 + assert data["total"] == 5 + assert data["page"] == 1 + + def test_list_bounties_search_filter(self, client): + _make_bounty("b1", title="Fix the login bug") + _make_bounty("b2", title="Add dark mode") + + data = client.get("/api/admin/bounties?search=login", headers=AUTH_HEADER).json() + assert data["total"] == 1 + assert data["items"][0]["id"] == "b1" + + def test_list_bounties_status_filter(self, client): + _make_bounty("b1", status=BountyStatus.OPEN) + _make_bounty("b2", status=BountyStatus.COMPLETED) + + data = client.get("/api/admin/bounties?status=open", headers=AUTH_HEADER).json() + assert data["total"] == 1 + assert data["items"][0]["id"] == "b1" + + def test_update_bounty_status(self, client): + _make_bounty("b1", status=BountyStatus.OPEN) + resp = client.patch( + "/api/admin/bounties/b1", + headers=AUTH_HEADER, + json={"status": "completed"}, + ) + assert resp.status_code == 200 + assert bounty_service._bounty_store["b1"].status == "completed" + + def test_update_bounty_reward(self, client): + _make_bounty("b1", reward=500.0) + resp = client.patch( + "/api/admin/bounties/b1", + headers=AUTH_HEADER, + json={"reward_amount": 1500.0}, + ) + assert resp.status_code == 200 + assert bounty_service._bounty_store["b1"].reward_amount == 1500.0 + + def test_update_nonexistent_bounty_404(self, client): + resp = client.patch( + "/api/admin/bounties/missing", + headers=AUTH_HEADER, + json={"status": "cancelled"}, + ) + assert resp.status_code == 404 + + def test_update_with_no_changes_400(self, client): + _make_bounty("b1") + resp = client.patch("/api/admin/bounties/b1", headers=AUTH_HEADER, json={}) + assert resp.status_code == 400 + + def test_close_bounty(self, client): + _make_bounty("b1", status=BountyStatus.IN_PROGRESS) + resp = client.post("/api/admin/bounties/b1/close", headers=AUTH_HEADER) + assert resp.status_code == 200 + assert bounty_service._bounty_store["b1"].status == BountyStatus.CANCELLED + + def test_close_nonexistent_bounty_404(self, client): + resp = client.post("/api/admin/bounties/missing/close", headers=AUTH_HEADER) + assert resp.status_code == 404 + + def test_close_bounty_writes_audit_log(self, client): + _make_bounty("b1") + client.post("/api/admin/bounties/b1/close", headers=AUTH_HEADER) + assert any(e["event"] == "admin_bounty_closed" for e in admin_module._audit_log) + + +# --------------------------------------------------------------------------- +# Contributor management +# --------------------------------------------------------------------------- + +class TestContributorManagement: + def test_list_contributors_empty(self, client): + data = client.get("/api/admin/contributors", headers=AUTH_HEADER).json() + assert data["total"] == 0 + + def test_list_contributors_banned_filter(self, client): + _make_contributor("c1", "alice", banned=False) + _make_contributor("c2", "bob", banned=True) + + data = client.get("/api/admin/contributors?is_banned=true", headers=AUTH_HEADER).json() + assert data["total"] == 1 + assert data["items"][0]["username"] == "bob" + + def test_ban_contributor(self, client): + _make_contributor("c1", "alice", banned=False) + resp = client.post( + "/api/admin/contributors/c1/ban", + headers=AUTH_HEADER, + json={"reason": "Spam submissions violating policy"}, + ) + assert resp.status_code == 200 + assert contributor_service._store["c1"].is_banned is True + + def test_ban_requires_reason(self, client): + _make_contributor("c1") + resp = client.post( + "/api/admin/contributors/c1/ban", + headers=AUTH_HEADER, + json={"reason": "ok"}, # too short (<5 chars) + ) + assert resp.status_code == 422 + + def test_unban_contributor(self, client): + _make_contributor("c1", "alice", banned=True) + resp = client.post("/api/admin/contributors/c1/unban", headers=AUTH_HEADER) + assert resp.status_code == 200 + assert contributor_service._store["c1"].is_banned is False + + def test_ban_nonexistent_contributor_404(self, client): + resp = client.post( + "/api/admin/contributors/missing/ban", + headers=AUTH_HEADER, + json={"reason": "Test reason here"}, + ) + assert resp.status_code == 404 + + def test_ban_writes_audit_entry(self, client): + _make_contributor("c1", "alice") + client.post( + "/api/admin/contributors/c1/ban", + headers=AUTH_HEADER, + json={"reason": "Policy violation reason"}, + ) + assert any(e["event"] == "admin_contributor_banned" for e in admin_module._audit_log) + + +# --------------------------------------------------------------------------- +# Review pipeline +# --------------------------------------------------------------------------- + +class TestReviewPipeline: + def test_empty_pipeline(self, client): + data = client.get("/api/admin/reviews/pipeline", headers=AUTH_HEADER).json() + assert data["total_active"] == 0 + assert data["pass_rate"] == 0.0 + assert data["avg_score"] == 0.0 + + +# --------------------------------------------------------------------------- +# Financial +# --------------------------------------------------------------------------- + +class TestFinancial: + def test_overview_zero_on_empty(self, client): + data = client.get("/api/admin/financial/overview", headers=AUTH_HEADER).json() + assert data["total_fndry_distributed"] == 0.0 + assert data["total_paid_bounties"] == 0 + + def test_overview_sums_paid_bounties(self, client): + _make_bounty("b1", status=BountyStatus.PAID, reward=1000.0) + _make_bounty("b2", status=BountyStatus.COMPLETED, reward=500.0) + _make_bounty("b3", status=BountyStatus.OPEN, reward=200.0) + + data = client.get("/api/admin/financial/overview", headers=AUTH_HEADER).json() + assert data["total_fndry_distributed"] == 1500.0 + assert data["total_paid_bounties"] == 2 + + def test_payout_history_pagination(self, client): + for i in range(5): + _make_bounty(f"b{i}", status=BountyStatus.PAID, reward=100.0) + + data = client.get("/api/admin/financial/payouts?page=1&per_page=3", headers=AUTH_HEADER).json() + assert len(data["items"]) == 3 + assert data["total"] == 5 + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- + +class TestAuditLog: + def test_empty_audit_log(self, client): + data = client.get("/api/admin/audit-log", headers=AUTH_HEADER).json() + assert data["entries"] == [] + assert data["total"] == 0 + + def test_audit_log_populated_by_actions(self, client): + _make_bounty("b1") + client.post("/api/admin/bounties/b1/close", headers=AUTH_HEADER) + + data = client.get("/api/admin/audit-log", headers=AUTH_HEADER).json() + assert data["total"] >= 1 + events = [e["event"] for e in data["entries"]] + assert "admin_bounty_closed" in events + + def test_audit_log_event_filter(self, client): + _make_bounty("b1") + _make_contributor("c1", "alice") + client.post("/api/admin/bounties/b1/close", headers=AUTH_HEADER) + client.post( + "/api/admin/contributors/c1/ban", + headers=AUTH_HEADER, + json={"reason": "Spamming the platform"}, + ) + + data = client.get("/api/admin/audit-log?event=banned", headers=AUTH_HEADER).json() + assert all("banned" in e["event"] for e in data["entries"]) + + def test_audit_log_limit(self, client): + for i in range(10): + _make_bounty(f"b{i}") + client.post(f"/api/admin/bounties/b{i}/close", headers=AUTH_HEADER) + + data = client.get("/api/admin/audit-log?limit=5", headers=AUTH_HEADER).json() + assert len(data["entries"]) <= 5 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d0aac61..a5ac6af4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,6 +79,7 @@ const HowItWorksPage = lazy(() => import('./pages/HowItWorksPage')); const DisputeListPage = lazy(() => import('./pages/DisputeListPage')); const DisputePage = lazy(() => import('./pages/DisputePage')); const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); +const AdminPage = lazy(() => import('./pages/AdminPage')); // ── Loading spinner ────────────────────────────────────────────────────────── function LoadingSpinner() { @@ -145,6 +146,19 @@ function AppLayout() { ); } +// ── Admin layout (bypasses SiteLayout — has its own shell) ─────────────────── +function AdminRoutes() { + return ( + + }> + + } /> + + + + ); +} + // ── Root App ───────────────────────────────────────────────────────────────── export default function App() { return ( @@ -153,7 +167,12 @@ export default function App() { - + + {/* Admin section — own layout, no wallet/site shell needed */} + } /> + {/* Everything else */} + } /> + diff --git a/frontend/src/__tests__/AdminDashboard.test.tsx b/frontend/src/__tests__/AdminDashboard.test.tsx new file mode 100644 index 00000000..fd7b4d5f --- /dev/null +++ b/frontend/src/__tests__/AdminDashboard.test.tsx @@ -0,0 +1,379 @@ +/** + * Tests for the admin dashboard frontend components. + * Uses msw-style mocking via vi.fn() to isolate from the network. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// --------------------------------------------------------------------------- +// Module mocks — hoisted above imports so vi.mock factories run before modules +// --------------------------------------------------------------------------- + +vi.mock('../hooks/useAdminData', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAdminToken: vi.fn(() => 'test-admin-key'), + setAdminToken: vi.fn(), + clearAdminToken: vi.fn(), + useAdminOverview: vi.fn(), + useAdminBounties: vi.fn(), + useUpdateBounty: vi.fn(), + useCloseBounty: vi.fn(), + useAdminContributors: vi.fn(), + useBanContributor: vi.fn(), + useUnbanContributor: vi.fn(), + useReviewPipeline: vi.fn(), + useFinancialOverview: vi.fn(), + usePayoutHistory: vi.fn(), + useSystemHealth: vi.fn(), + useAuditLog: vi.fn(), + }; +}); + +vi.mock('../hooks/useAdminWebSocket', () => ({ + useAdminWebSocket: vi.fn(() => ({ status: 'connected', lastEvent: null, disconnect: vi.fn() })), +})); + +import * as adminData from '../hooks/useAdminData'; +import { OverviewPanel } from '../components/admin/OverviewPanel'; +import { BountyManagement } from '../components/admin/BountyManagement'; +import { ContributorManagement } from '../components/admin/ContributorManagement'; +import { ReviewPipeline } from '../components/admin/ReviewPipeline'; +import { SystemHealth } from '../components/admin/SystemHealth'; +import { AuditLogPanel } from '../components/admin/AuditLogPanel'; +import { AdminLayout } from '../components/admin/AdminLayout'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeQC() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function Wrapper({ children, qc }: { children: ReactNode; qc: QueryClient }) { + return ( + + + {children} + + + ); +} + +function noopQuery() { + return { data: undefined, isLoading: false, error: null, dataUpdatedAt: 0 }; +} + +function noopMutation() { + return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isError: false, error: null }; +} + +// --------------------------------------------------------------------------- +// AdminLayout — auth gate +// --------------------------------------------------------------------------- + +describe('AdminLayout auth gate', () => { + it('shows login form when no token is stored', () => { + vi.mocked(adminData.getAdminToken).mockReturnValue(''); + const qc = makeQC(); + render( + + +
content
+
+
, + ); + expect(screen.getByTestId('admin-login-form')).toBeDefined(); + expect(screen.queryByTestId('admin-layout')).toBeNull(); + }); + + it('renders layout when token is present', () => { + vi.mocked(adminData.getAdminToken).mockReturnValue('secret-key'); + const qc = makeQC(); + render( + + +
content
+
+
, + ); + expect(screen.getByTestId('admin-layout')).toBeDefined(); + expect(screen.getByTestId('child')).toBeDefined(); + }); + + it('shows login form after sign out', async () => { + vi.mocked(adminData.getAdminToken).mockReturnValue('secret-key'); + const qc = makeQC(); + render( + + +
content
+
+
, + ); + fireEvent.click(screen.getByTestId('admin-signout')); + await waitFor(() => expect(screen.getByTestId('admin-login-form')).toBeDefined()); + }); + + it('saves token and shows layout on login', async () => { + vi.mocked(adminData.getAdminToken).mockReturnValue(''); + const qc = makeQC(); + render( + + +
content
+
+
, + ); + fireEvent.change(screen.getByTestId('admin-key-input'), { target: { value: 'mykey' } }); + fireEvent.click(screen.getByTestId('admin-login-btn')); + expect(adminData.setAdminToken).toHaveBeenCalledWith('mykey'); + }); +}); + +// --------------------------------------------------------------------------- +// OverviewPanel +// --------------------------------------------------------------------------- + +describe('OverviewPanel', () => { + it('shows skeleton while loading', () => { + vi.mocked(adminData.useAdminOverview).mockReturnValue({ ...noopQuery(), isLoading: true } as ReturnType); + const qc = makeQC(); + const { container } = render(); + const pulsingDivs = container.querySelectorAll('.animate-pulse'); + expect(pulsingDivs.length).toBeGreaterThan(0); + }); + + it('shows error message on failure', () => { + vi.mocked(adminData.useAdminOverview).mockReturnValue({ + ...noopQuery(), + error: new Error('Network error'), + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByRole('alert').textContent).toContain('Network error'); + }); + + it('renders stat cards with data', () => { + vi.mocked(adminData.useAdminOverview).mockReturnValue({ + ...noopQuery(), + data: { + total_bounties: 42, + open_bounties: 10, + completed_bounties: 30, + cancelled_bounties: 2, + total_contributors: 15, + active_contributors: 14, + banned_contributors: 1, + total_fndry_paid: 50000, + total_submissions: 100, + pending_reviews: 3, + uptime_seconds: 3600, + timestamp: new Date().toISOString(), + }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('42')).toBeDefined(); + expect(screen.getByText('15')).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// BountyManagement +// --------------------------------------------------------------------------- + +describe('BountyManagement', () => { + beforeEach(() => { + vi.mocked(adminData.useUpdateBounty).mockReturnValue(noopMutation() as ReturnType); + vi.mocked(adminData.useCloseBounty).mockReturnValue(noopMutation() as ReturnType); + }); + + it('shows empty state when no bounties', () => { + vi.mocked(adminData.useAdminBounties).mockReturnValue({ + ...noopQuery(), + data: { items: [], total: 0, page: 1, per_page: 20 }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('No bounties found')).toBeDefined(); + }); + + it('renders bounty rows', () => { + vi.mocked(adminData.useAdminBounties).mockReturnValue({ + ...noopQuery(), + data: { + items: [ + { id: 'b1', title: 'Fix the auth bug', status: 'open', tier: 1, reward_amount: 500, created_by: 'alice', deadline: '2026-04-01T00:00:00Z', submission_count: 3, created_at: '2026-03-01T00:00:00Z' }, + ], + total: 1, + page: 1, + per_page: 20, + }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('Fix the auth bug')).toBeDefined(); + expect(screen.getByTestId('bounty-row-b1')).toBeDefined(); + }); + + it('opens edit modal on Edit click', () => { + vi.mocked(adminData.useAdminBounties).mockReturnValue({ + ...noopQuery(), + data: { + items: [ + { id: 'b1', title: 'Fix bug', status: 'open', tier: 1, reward_amount: 500, created_by: 'alice', deadline: '2026-04-01T00:00:00Z', submission_count: 0, created_at: '2026-03-01T00:00:00Z' }, + ], + total: 1, + page: 1, + per_page: 20, + }, + } as ReturnType); + const qc = makeQC(); + render(); + fireEvent.click(screen.getByTestId('edit-bounty-b1')); + expect(screen.getByTestId('bounty-edit-modal')).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// ContributorManagement +// --------------------------------------------------------------------------- + +describe('ContributorManagement', () => { + beforeEach(() => { + vi.mocked(adminData.useBanContributor).mockReturnValue(noopMutation() as ReturnType); + vi.mocked(adminData.useUnbanContributor).mockReturnValue(noopMutation() as ReturnType); + }); + + it('renders contributor rows', () => { + vi.mocked(adminData.useAdminContributors).mockReturnValue({ + ...noopQuery(), + data: { + items: [ + { id: 'c1', username: 'alice', display_name: 'Alice', tier: 'T2', reputation_score: 85.5, total_bounties_completed: 5, total_earnings: 2500, is_banned: false, skills: ['React'], created_at: '2026-01-01T00:00:00Z' }, + ], + total: 1, + page: 1, + per_page: 20, + }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('@alice')).toBeDefined(); + expect(screen.getByTestId('ban-c1')).toBeDefined(); + }); + + it('shows unban button for banned contributors', () => { + vi.mocked(adminData.useAdminContributors).mockReturnValue({ + ...noopQuery(), + data: { + items: [ + { id: 'c2', username: 'bob', display_name: 'Bob', tier: 'T1', reputation_score: 20.0, total_bounties_completed: 1, total_earnings: 100, is_banned: true, skills: [], created_at: '2026-01-01T00:00:00Z' }, + ], + total: 1, + page: 1, + per_page: 20, + }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByTestId('unban-c2')).toBeDefined(); + }); + + it('opens ban modal when ban button clicked', () => { + vi.mocked(adminData.useAdminContributors).mockReturnValue({ + ...noopQuery(), + data: { + items: [ + { id: 'c1', username: 'alice', display_name: 'Alice', tier: 'T1', reputation_score: 10, total_bounties_completed: 0, total_earnings: 0, is_banned: false, skills: [], created_at: '2026-01-01T00:00:00Z' }, + ], + total: 1, + page: 1, + per_page: 20, + }, + } as ReturnType); + const qc = makeQC(); + render(); + fireEvent.click(screen.getByTestId('ban-c1')); + expect(screen.getByTestId('ban-modal')).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// ReviewPipeline +// --------------------------------------------------------------------------- + +describe('ReviewPipeline', () => { + it('shows zero-state metrics when pipeline is empty', () => { + vi.mocked(adminData.useReviewPipeline).mockReturnValue({ + ...noopQuery(), + data: { active: [], total_active: 0, pass_rate: 0.0, avg_score: 0.0 }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('0')).toBeDefined(); + expect(screen.getByText('No pending reviews')).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// SystemHealth +// --------------------------------------------------------------------------- + +describe('SystemHealth', () => { + it('renders healthy status', () => { + vi.mocked(adminData.useSystemHealth).mockReturnValue({ + ...noopQuery(), + data: { + status: 'healthy', + uptime_seconds: 7200, + timestamp: new Date().toISOString(), + services: { database: 'connected', redis: 'connected' }, + queue_depth: 0, + webhook_events_processed: 12, + active_websocket_connections: 3, + }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('healthy')).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// AuditLogPanel +// --------------------------------------------------------------------------- + +describe('AuditLogPanel', () => { + it('shows empty state when no entries', () => { + vi.mocked(adminData.useAuditLog).mockReturnValue({ + ...noopQuery(), + data: { entries: [], total: 0 }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('No audit events recorded yet')).toBeDefined(); + }); + + it('renders audit entries', () => { + vi.mocked(adminData.useAuditLog).mockReturnValue({ + ...noopQuery(), + data: { + entries: [ + { event: 'admin_bounty_closed', actor: 'admin', timestamp: new Date().toISOString(), details: { bounty_id: 'b1' } }, + ], + total: 1, + }, + } as ReturnType); + const qc = makeQC(); + render(); + expect(screen.getByText('admin_bounty_closed')).toBeDefined(); + expect(screen.getByTestId('audit-entry-0')).toBeDefined(); + }); +}); diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx new file mode 100644 index 00000000..98c1bdb6 --- /dev/null +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -0,0 +1,171 @@ +/** + * AdminLayout — sidebar + header shell for all admin panels. + * Handles auth gate: prompts for API key if none stored. + */ +import { useState, type ReactNode } from 'react'; +import { useAdminWebSocket } from '../../hooks/useAdminWebSocket'; +import { getAdminToken, setAdminToken, clearAdminToken } from '../../hooks/useAdminData'; +import type { AdminSection } from '../../types/admin'; + +interface NavItem { + id: AdminSection; + label: string; + icon: string; +} + +const NAV_ITEMS: NavItem[] = [ + { id: 'overview', label: 'Overview', icon: '◈' }, + { id: 'bounties', label: 'Bounties', icon: '⬡' }, + { id: 'contributors', label: 'Contributors', icon: '⬡' }, + { id: 'reviews', label: 'Review Pipeline', icon: '⬡' }, + { id: 'financial', label: 'Financial', icon: '⬡' }, + { id: 'health', label: 'System Health', icon: '⬡' }, + { id: 'audit-log', label: 'Audit Log', icon: '⬡' }, +]; + +interface Props { + active: AdminSection; + onNavigate: (s: AdminSection) => void; + children: ReactNode; +} + +// --------------------------------------------------------------------------- +// Auth gate +// --------------------------------------------------------------------------- + +function AdminLoginForm({ onSuccess }: { onSuccess: () => void }) { + const [key, setKey] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!key.trim()) { setError('API key is required'); return; } + setAdminToken(key.trim()); + onSuccess(); + }; + + return ( +
+
+
+ + SolFoundry + +

Admin Dashboard

+
+ +
+ + { setKey(e.target.value); setError(''); }} + placeholder="Enter admin API key…" + className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#9945FF]/50" + autoComplete="current-password" + data-testid="admin-key-input" + /> + {error &&

{error}

} +
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Layout shell +// --------------------------------------------------------------------------- + +export function AdminLayout({ active, onNavigate, children }: Props) { + const [authed, setAuthed] = useState(() => Boolean(getAdminToken())); + const { status: wsStatus } = useAdminWebSocket(); + + if (!authed) { + return setAuthed(true)} />; + } + + const wsColor = + wsStatus === 'connected' ? 'bg-[#14F195]' : + wsStatus === 'connecting' ? 'bg-yellow-400' : + wsStatus === 'error' ? 'bg-red-500' : 'bg-gray-600'; + + const handleSignOut = () => { + clearAdminToken(); + setAuthed(false); + }; + + return ( +
+ + {/* ── Sidebar ────────────────────────────────────────────────── */} + + + {/* ── Main content ────────────────────────────────────────────── */} +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/admin/AuditLogPanel.tsx b/frontend/src/components/admin/AuditLogPanel.tsx new file mode 100644 index 00000000..52b51f12 --- /dev/null +++ b/frontend/src/components/admin/AuditLogPanel.tsx @@ -0,0 +1,124 @@ +/** Audit log panel — timestamped admin action log with search and filtering. */ +import { useState } from 'react'; +import { useAuditLog } from '../../hooks/useAdminData'; + +const EVENT_COLORS: Record = { + admin_bounty_updated: 'text-[#9945FF]', + admin_bounty_closed: 'text-red-400', + admin_contributor_banned: 'text-red-400', + admin_contributor_unbanned: 'text-[#14F195]', +}; + +function eventColor(event: string) { + return EVENT_COLORS[event] ?? 'text-gray-400'; +} + +function relativeTime(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const s = Math.floor(diff / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return new Date(iso).toLocaleDateString(); +} + +export function AuditLogPanel() { + const [eventFilter, setEventFilter] = useState(''); + const [limit, setLimit] = useState(50); + + const { data, isLoading, error } = useAuditLog(limit, eventFilter || undefined); + + return ( +
+
+

Audit Log

+ {data && ( + + {data.total} entries (showing {data.entries.length}) + + )} +
+ + {/* Filters */} +
+ setEventFilter(e.target.value)} + className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#9945FF]/50 w-64" + data-testid="audit-event-filter" + /> + +
+ + {/* Log entries */} + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ )} + + {error &&

{(error as Error).message}

} + + {data && data.entries.length === 0 && ( +
+

+ {eventFilter ? `No entries matching "${eventFilter}"` : 'No audit events recorded yet'} +

+
+ )} + + {data && data.entries.length > 0 && ( +
+ {data.entries.map((entry, i) => ( +
+ {/* Timestamp */} + + {relativeTime(entry.timestamp)} + + + {/* Event */} + + {entry.event} + + + {/* Actor */} + {entry.actor} + + {/* Details */} +
+ {Object.entries(entry.details).map(([k, v]) => ( + + {k}:{' '} + + {typeof v === 'object' ? JSON.stringify(v) : String(v)} + + + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/BountyManagement.tsx b/frontend/src/components/admin/BountyManagement.tsx new file mode 100644 index 00000000..54b6e050 --- /dev/null +++ b/frontend/src/components/admin/BountyManagement.tsx @@ -0,0 +1,267 @@ +/** Bounty management panel — search, filter, edit status/reward, close. */ +import { useState } from 'react'; +import { useAdminBounties, useUpdateBounty, useCloseBounty } from '../../hooks/useAdminData'; +import type { BountyAdminItem } from '../../types/admin'; + +const STATUS_COLORS: Record = { + open: 'text-[#14F195] bg-[#14F195]/10', + in_progress: 'text-yellow-400 bg-yellow-400/10', + under_review: 'text-blue-400 bg-blue-400/10', + completed: 'text-[#9945FF] bg-[#9945FF]/10', + paid: 'text-[#9945FF] bg-[#9945FF]/10', + cancelled: 'text-gray-500 bg-gray-500/10', + disputed: 'text-red-400 bg-red-400/10', +}; + +const STATUSES = ['open', 'in_progress', 'under_review', 'completed', 'paid', 'cancelled', 'disputed']; + +interface EditModalProps { + bounty: BountyAdminItem; + onClose: () => void; +} + +function EditModal({ bounty, onClose }: EditModalProps) { + const [status, setStatus] = useState(bounty.status); + const [reward, setReward] = useState(String(bounty.reward_amount)); + const [title, setTitle] = useState(bounty.title); + const update = useUpdateBounty(); + const close = useCloseBounty(); + + const handleSave = async () => { + const patch: Record = {}; + if (status !== bounty.status) patch.status = status; + const rewardNum = Number(reward); + if (!isNaN(rewardNum) && rewardNum !== bounty.reward_amount) patch.reward_amount = rewardNum; + if (title !== bounty.title) patch.title = title; + if (Object.keys(patch).length === 0) { onClose(); return; } + await update.mutateAsync({ id: bounty.id, update: patch }); + onClose(); + }; + + const handleClose = async () => { + await close.mutateAsync(bounty.id); + onClose(); + }; + + return ( +
+
+

Edit Bounty

+

{bounty.id}

+ + {/* Title */} +
+ + setTitle(e.target.value)} + className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#9945FF]/50" + data-testid="edit-title" + /> +
+ + {/* Status */} +
+ + +
+ + {/* Reward */} +
+ + setReward(e.target.value)} + className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#9945FF]/50" + data-testid="edit-reward" + /> +
+ + {update.isError && ( +

{(update.error as Error).message}

+ )} + + {/* Actions */} +
+ + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main panel +// --------------------------------------------------------------------------- + +export function BountyManagement() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [page, setPage] = useState(1); + const [editing, setEditing] = useState(null); + + const { data, isLoading, error } = useAdminBounties({ + search: search || undefined, + status: statusFilter || undefined, + page, + perPage: 20, + }); + + const totalPages = data ? Math.ceil(data.total / 20) : 1; + + return ( +
+ {editing && setEditing(null)} />} + +
+

Bounty Management

+ {data && ( + + {data.total} total + + )} +
+ + {/* Filters */} +
+ { setSearch(e.target.value); setPage(1); }} + className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#9945FF]/50 w-56" + data-testid="bounty-search-input" + /> + +
+ + {/* Table */} + {isLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ )} + + {error &&

{(error as Error).message}

} + + {data && data.items.length === 0 && ( +

No bounties found

+ )} + + {data && data.items.length > 0 && ( +
+ + + + + + + + + + + + + {data.items.map(b => ( + + + + + + + + + ))} + +
TitleStatusTierRewardSubmissionsActions
{b.title} + + {b.status} + + T{b.tier} + {b.reward_amount.toLocaleString()} + + {b.submission_count} + + +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/ContributorManagement.tsx b/frontend/src/components/admin/ContributorManagement.tsx new file mode 100644 index 00000000..229dea3e --- /dev/null +++ b/frontend/src/components/admin/ContributorManagement.tsx @@ -0,0 +1,245 @@ +/** Contributor management panel — search, ban/unban, tier and score display. */ +import { useState } from 'react'; +import { + useAdminContributors, + useBanContributor, + useUnbanContributor, +} from '../../hooks/useAdminData'; +import type { ContributorAdminItem } from '../../types/admin'; + +interface BanModalProps { + contributor: ContributorAdminItem; + onClose: () => void; +} + +function BanModal({ contributor, onClose }: BanModalProps) { + const [reason, setReason] = useState(''); + const ban = useBanContributor(); + + const handleBan = async () => { + if (reason.trim().length < 5) return; + await ban.mutateAsync({ id: contributor.id, reason: reason.trim() }); + onClose(); + }; + + return ( +
+
+

Ban Contributor

+

+ Banning @{contributor.username} +

+ +
+ +