diff --git a/backend/alembic/versions/003_add_admin_audit_log.py b/backend/alembic/versions/003_add_admin_audit_log.py new file mode 100644 index 00000000..189af929 --- /dev/null +++ b/backend/alembic/versions/003_add_admin_audit_log.py @@ -0,0 +1,47 @@ +"""Add admin_audit_log table for persistent admin action tracking. + +Revision ID: 003_admin_audit_log +Revises: 002_disputes +Create Date: 2026-03-22 + +Replaces the in-memory deque in admin.py with a persistent PostgreSQL +table so audit entries survive restarts and are queryable with filters. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "003_admin_audit_log" +down_revision: Union[str, None] = "002_disputes" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "admin_audit_log", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("event", sa.String(100), nullable=False, index=True), + sa.Column("actor", sa.String(200), nullable=False), + sa.Column("role", sa.String(20), nullable=False, server_default="admin"), + sa.Column("details", postgresql.JSONB, nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + index=True, + ), + ) + + +def downgrade() -> None: + op.drop_table("admin_audit_log") diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 00000000..00aa62c4 --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,907 @@ +"""Admin dashboard API — management endpoints for bounties, contributors, +reviews, financials, system health, and audit log. + +Authentication: Accepts either a GitHub OAuth JWT (sub = GitHub username, +checked against ADMIN_GITHUB_USERS / REVIEWER_GITHUB_USERS / VIEWER_GITHUB_USERS +env vars) or a legacy ADMIN_API_KEY for backward compatibility. + +Roles: + admin — full access (GitHub username in ADMIN_GITHUB_USERS, or API key) + reviewer — read + approve/reject reviews (REVIEWER_GITHUB_USERS) + viewer — read-only (VIEWER_GITHUB_USERS) + +Environment variables: + ADMIN_API_KEY Legacy shared secret; grants admin role. + ADMIN_GITHUB_USERS Comma-separated GitHub usernames with admin role. + REVIEWER_GITHUB_USERS Comma-separated GitHub usernames with reviewer role. + VIEWER_GITHUB_USERS Comma-separated GitHub usernames with viewer role. +""" + +import os +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Literal, 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.database import get_db_session +from app.models.tables import AdminAuditLogTable +from app.services.bounty_service import _bounty_store +from app.services.contributor_service import _store as _contributor_store +from app.models.bounty import BountyStatus, BountyCreate, VALID_STATUS_TRANSITIONS +from app.constants import START_TIME + +# --------------------------------------------------------------------------- +# Configuration — captured at import time so tests can patch at the module level +# --------------------------------------------------------------------------- + +_ADMIN_API_KEY: str = os.getenv("ADMIN_API_KEY", "") + +def _csv_env(key: str) -> set[str]: + """Return a set of lowercased usernames from a comma-separated env var.""" + raw = os.getenv(key, "") + return {u.strip().lower() for u in raw.split(",") if u.strip()} + + +_security = HTTPBearer(auto_error=False) + +AdminRole = Literal["admin", "reviewer", "viewer"] + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +# --------------------------------------------------------------------------- +# RBAC auth dependency +# --------------------------------------------------------------------------- + + +async def _resolve_role(credentials: Optional[HTTPAuthorizationCredentials]) -> tuple[str, AdminRole]: + """Resolve (actor, role) from a Bearer token. + + Accepts: + - A GitHub OAuth JWT → decodes sub (GitHub username) → checks role sets + - A legacy ADMIN_API_KEY string → returns ("admin", "admin") + + Raises HTTPException 401/403/503 on failure. + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Admin authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + + # ── Try JWT first ──────────────────────────────────────────────────────── + try: + from app.services.auth_service import decode_token, InvalidTokenError, TokenExpiredError + + username: str = decode_token(token, token_type="access").lower() + + admin_users = _csv_env("ADMIN_GITHUB_USERS") + reviewer_users = _csv_env("REVIEWER_GITHUB_USERS") + viewer_users = _csv_env("VIEWER_GITHUB_USERS") + + if username in admin_users: + return username, "admin" + if username in reviewer_users: + return username, "reviewer" + if username in viewer_users: + return username, "viewer" + + # GitHub users that are authenticated but not in any role set are denied. + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"GitHub user '{username}' does not have admin dashboard access", + ) + + except HTTPException: + raise + except Exception: + # Not a valid JWT — fall through to API key check + pass + + # ── Fall back to legacy ADMIN_API_KEY ──────────────────────────────────── + if not _ADMIN_API_KEY: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Admin authentication is not configured on this server", + ) + if token != _ADMIN_API_KEY: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid admin credentials", + ) + return "admin", "admin" + + +async def require_admin( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_security), +) -> str: + """Require admin role; return actor string.""" + actor, role = await _resolve_role(credentials) + if role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Role '{role}' is insufficient — admin required", + ) + return actor + + +async def require_reviewer( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_security), +) -> tuple[str, AdminRole]: + """Require reviewer or admin role; return (actor, role).""" + actor, role = await _resolve_role(credentials) + if role not in ("admin", "reviewer"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Role '{role}' is insufficient — reviewer or admin required", + ) + return actor, role + + +async def require_any( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_security), +) -> tuple[str, AdminRole]: + """Require any valid admin role; return (actor, role).""" + return await _resolve_role(credentials) + + +# --------------------------------------------------------------------------- +# Persistent audit log +# --------------------------------------------------------------------------- + + +async def _log(event: str, actor: str, role: str = "admin", **details: Any) -> None: + """Insert an audit entry into PostgreSQL and emit to structlog.""" + audit_event(event, actor=actor, **details) + try: + async with get_db_session() as session: + row = AdminAuditLogTable( + id=uuid.uuid4(), + event=event, + actor=actor, + role=role, + details=details, + created_at=datetime.now(timezone.utc), + ) + session.add(row) + await session.commit() + except Exception: + # Never let audit log failures bubble up to the caller + pass + + +# --------------------------------------------------------------------------- +# Serialisation helpers +# --------------------------------------------------------------------------- + + +def _bounty_to_dict(b: Any) -> Dict[str, Any]: + 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]: + 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), + "quality_score": _calculate_quality_score(c), + "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", "")) + ), + } + + +def _calculate_quality_score(c: Any) -> float: + """Derive a 0–100 quality score from reputation + completion rate.""" + rep = float(getattr(c, "reputation_score", 0.0)) + completed = int(getattr(c, "total_bounties_completed", 0)) + # Simple formula: blend reputation (max ~500) and completion volume + rep_component = min(rep / 5.0, 80.0) + volume_component = min(completed * 2.0, 20.0) + return round(rep_component + volume_component, 1) + + +# --------------------------------------------------------------------------- +# Response models +# --------------------------------------------------------------------------- + + +class AdminOverview(BaseModel): + 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) + title: Optional[str] = Field(None, min_length=3, max_length=200) + + +class BountyAdminCreate(BaseModel): + """Payload for admin-created bounties.""" + title: str = Field(..., min_length=3, max_length=200) + description: str = Field(..., min_length=10, max_length=5000) + tier: int = Field(..., ge=1, le=3) + reward_amount: float = Field(..., gt=0) + deadline: Optional[str] = Field(None, description="ISO-8601 deadline") + tags: List[str] = Field(default_factory=list) + + +class ContributorAdminItem(BaseModel): + id: str + username: str + display_name: str + tier: str + reputation_score: float + quality_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 TierHistoryItem(BaseModel): + tier: str + reputation_score: float + bounty_id: Optional[str] + bounty_title: Optional[str] + earned_reputation: float + created_at: str + + +class TierHistoryResponse(BaseModel): + contributor_id: str + items: List[TierHistoryItem] + total: int + + +class BanRequest(BaseModel): + reason: str = Field(..., min_length=5, max_length=500) + + +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 + 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 + bot_uptime_seconds: int + timestamp: str + services: Dict[str, str] + queue_depth: int + webhook_events_processed: int + github_webhook_status: str + active_websocket_connections: int + + +class AuditLogEntry(BaseModel): + event: str + actor: str + role: str = "admin" + 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(auth: tuple = Depends(require_any)) -> AdminOverview: + 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 not getattr(s, "review_complete", 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") +async def list_bounties_admin( + search: Optional[str] = Query(None), + status_filter: Optional[str] = Query(None, alias="status"), + tier: Optional[int] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + _auth: tuple = Depends(require_any), +) -> BountyListAdminResponse: + 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] + + items.sort(key=lambda b: getattr(b, "created_at", datetime.min), reverse=True) + total = len(items) + offset = (page - 1) * per_page + + return BountyListAdminResponse( + items=[BountyAdminItem(**_bounty_to_dict(b)) for b in items[offset: offset + per_page]], + total=total, + page=page, + per_page=per_page, + ) + + +@router.post("/bounties", summary="Create a new bounty (admin)") +async def create_bounty_admin( + payload: BountyAdminCreate, + actor: str = Depends(require_admin), +) -> Dict[str, Any]: + """Create a bounty directly from the admin dashboard.""" + from app.services.bounty_service import create_bounty + from app.models.bounty import BountyTier + + create_data = BountyCreate( + title=payload.title, + description=payload.description, + tier=BountyTier(payload.tier), + reward_amount=payload.reward_amount, + deadline=payload.deadline, + required_skills=payload.tags, + created_by=actor, + github_issue_url=None, + ) + bounty = await create_bounty(create_data) + await _log("admin_bounty_created", actor=actor, bounty_id=bounty.id, title=payload.title) + return {"ok": True, "bounty_id": bounty.id} + + +@router.patch("/bounties/{bounty_id}", summary="Update a bounty") +async def update_bounty_admin( + bounty_id: str, + update: BountyAdminUpdate, + actor: str = Depends(require_admin), +) -> Dict[str, Any]: + 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: + # Validate against lifecycle transitions + try: + new_status = BountyStatus(update.status) + except ValueError: + valid = [s.value for s in BountyStatus] + raise HTTPException( + status_code=400, + detail=f"Invalid status {update.status!r}. Valid values: {valid}", + ) + current = BountyStatus(bounty.status) + allowed = VALID_STATUS_TRANSITIONS.get(current, set()) + if new_status not in allowed and new_status != current: + raise HTTPException( + status_code=400, + detail=( + f"Transition {current.value!r} → {new_status.value!r} is not allowed. " + f"Allowed transitions: {[s.value for s in allowed]}" + ), + ) + bounty.status = new_status + changes["status"] = new_status.value + + 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") + + await _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]: + 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 + await _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), + is_banned: Optional[bool] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + _auth: tuple = Depends(require_any), +) -> ContributorListAdminResponse: + 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 + + return ContributorListAdminResponse( + items=[ContributorAdminItem(**_contributor_to_dict(c)) for c in items[offset: offset + per_page]], + total=total, + page=page, + per_page=per_page, + ) + + +@router.get( + "/contributors/{contributor_id}/history", + response_model=TierHistoryResponse, + summary="Contributor tier + reputation history", +) +async def get_contributor_history( + contributor_id: str, + limit: int = Query(50, ge=1, le=200), + _auth: tuple = Depends(require_any), +) -> TierHistoryResponse: + """Return per-bounty reputation history from PostgreSQL reputation_history table.""" + from sqlalchemy import select as sa_select, desc + from app.models.tables import ReputationHistoryTable + + try: + async with get_db_session() as session: + q = ( + sa_select(ReputationHistoryTable) + .where(ReputationHistoryTable.contributor_id == contributor_id) + .order_by(desc(ReputationHistoryTable.created_at)) + .limit(limit) + ) + result = await session.execute(q) + rows = result.scalars().all() + except Exception: + rows = [] + + items = [ + TierHistoryItem( + tier=str(r.bounty_tier), + reputation_score=float(r.earned_reputation), + bounty_id=r.bounty_id, + bounty_title=r.bounty_title, + earned_reputation=float(r.earned_reputation), + created_at=r.created_at.isoformat() if hasattr(r.created_at, "isoformat") else str(r.created_at), + ) + for r in rows + ] + return TierHistoryResponse(contributor_id=contributor_id, items=items, total=len(items)) + + +@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]: + 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 + await _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]: + 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 + await _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") +async def get_review_pipeline(_auth: tuple = Depends(require_any)) -> ReviewPipelineResponse: + 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 = float(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 summary") +async def get_financial_overview(_auth: tuple = Depends(require_any)) -> FinancialOverview: + 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 + + return FinancialOverview( + total_fndry_distributed=total_distributed, + total_paid_bounties=len(paid), + pending_payout_count=len(pending), + pending_payout_amount=sum(b.reward_amount for b in pending), + 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), + _auth: tuple = Depends(require_any), +) -> PayoutHistoryResponse: + paid_bounties = sorted( + [b for b in _bounty_store.values() if b.status in (BountyStatus.PAID, BountyStatus.COMPLETED)], + 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] + + return PayoutHistoryResponse( + 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 + ], + total=total, + ) + + +# --------------------------------------------------------------------------- +# System health (enhanced) +# --------------------------------------------------------------------------- + + +@router.get("/system/health", response_model=SystemHealthResponse, summary="System health") +async def get_system_health_admin(_auth: tuple = Depends(require_any)) -> SystemHealthResponse: + from app.database import engine + from sqlalchemy import text, select as sa_select, func + from sqlalchemy.exc import SQLAlchemyError + import os as _os + from redis.asyncio import from_url as redis_from_url, RedisError + + # Database 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 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 + + # Audit log count from PostgreSQL (real webhook events processed) + webhook_count = 0 + try: + async with get_db_session() as session: + result = await session.execute( + sa_select(func.count()).select_from(AdminAuditLogTable) + ) + webhook_count = result.scalar_one_or_none() or 0 + except Exception: + pass + + # GitHub webhook service status: check if the webhook router is reachable + try: + from app.api.webhooks.github import router as _gh_router + github_webhook_status = "configured" if _gh_router else "not_configured" + except Exception: + github_webhook_status = "not_configured" + + # Pending review queue depth + 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" + ) + + uptime = round(time.monotonic() - START_TIME) + + return SystemHealthResponse( + status="healthy" if db_status == "connected" else "degraded", + uptime_seconds=uptime, + bot_uptime_seconds=uptime, + timestamp=datetime.now(timezone.utc).isoformat(), + services={ + "database": db_status, + "redis": redis_status, + "github_webhook": github_webhook_status, + }, + queue_depth=pending_reviews, + webhook_events_processed=webhook_count, + github_webhook_status=github_webhook_status, + active_websocket_connections=ws_count, + ) + + +# --------------------------------------------------------------------------- +# Audit log (PostgreSQL-backed) +# --------------------------------------------------------------------------- + + +@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"), + _auth: tuple = Depends(require_any), +) -> AuditLogResponse: + """Return recent admin audit log entries from PostgreSQL, newest first.""" + from sqlalchemy import select as sa_select, desc + + try: + from sqlalchemy import func as sa_func + async with get_db_session() as session: + base_filter = AdminAuditLogTable.event.contains(event_filter) if event_filter else None + + count_stmt = sa_select(sa_func.count()).select_from(AdminAuditLogTable) + if base_filter is not None: + count_stmt = count_stmt.where(base_filter) + total_result = await session.execute(count_stmt) + total = total_result.scalar_one_or_none() or 0 + + q = sa_select(AdminAuditLogTable).order_by(desc(AdminAuditLogTable.created_at)) + if base_filter is not None: + q = q.where(base_filter) + q = q.limit(limit) + result = await session.execute(q) + rows = result.scalars().all() + except Exception: + return AuditLogResponse(entries=[], total=0) + + return AuditLogResponse( + entries=[ + AuditLogEntry( + event=r.event, + actor=r.actor, + role=r.role or "admin", + timestamp=r.created_at.isoformat() if hasattr(r.created_at, "isoformat") else str(r.created_at), + details=r.details or {}, + ) + for r in rows + ], + 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/app/models/tables.py b/backend/app/models/tables.py index 186a20f6..38129499 100644 --- a/backend/app/models/tables.py +++ b/backend/app/models/tables.py @@ -134,3 +134,22 @@ class BountySubmissionTable(Base): "ix_bsub_bounty_pr", "bounty_id", "pr_url", unique=True ), ) + + +class AdminAuditLogTable(Base): + """Persistent audit log for all admin actions. + + Replaces the in-memory deque so entries survive restarts and are + queryable with time-range and event-type filters. + """ + + __tablename__ = "admin_audit_log" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + event = Column(String(100), nullable=False, index=True) + actor = Column(String(200), nullable=False) + role = Column(String(20), nullable=False, server_default="admin") + details = Column(sa.JSON, nullable=False, default=dict) + created_at = Column( + DateTime(timezone=True), nullable=False, default=_now, index=True + ) 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..d8f64e96 --- /dev/null +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -0,0 +1,251 @@ +/** + * AdminLayout — sidebar + header shell for all admin panels. + * + * Auth gate: prompts GitHub OAuth sign-in. After OAuth, the JWT is stored in + * sessionStorage under sf_admin_token and used as a Bearer token for all + * admin API requests. + * + * WebSocket: passes an onEvent handler that invalidates React Query caches + * for the relevant keys so panels update in real time without polling. + */ +import { useState, useCallback, type ReactNode } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAdminWebSocket, type AdminWsEvent } from '../../hooks/useAdminWebSocket'; +import { getAdminToken, setAdminToken, clearAdminToken } from '../../hooks/useAdminData'; +import type { AdminSection } from '../../types/admin'; + +const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +interface NavItem { + id: AdminSection; + label: string; +} + +const NAV_ITEMS: NavItem[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'bounties', label: 'Bounties' }, + { id: 'contributors', label: 'Contributors' }, + { id: 'reviews', label: 'Review Pipeline' }, + { id: 'financial', label: 'Financial' }, + { id: 'health', label: 'System Health' }, + { id: 'audit-log', label: 'Audit Log' }, +]; + +interface Props { + active: AdminSection; + onNavigate: (s: AdminSection) => void; + children: ReactNode; +} + +// --------------------------------------------------------------------------- +// Auth gate — GitHub OAuth primary, API key fallback +// --------------------------------------------------------------------------- + +function AdminLoginForm({ onSuccess }: { onSuccess: () => void }) { + const [showKeyForm, setShowKeyForm] = useState(false); + const [key, setKey] = useState(''); + const [error, setError] = useState(''); + + /** Redirect to GitHub OAuth — on return the token lands via URL param. */ + const handleGitHubLogin = () => { + // Store a flag so AdminPage can capture the token on redirect-back + sessionStorage.setItem('sf_admin_oauth_pending', '1'); + window.location.href = `${API_BASE}/api/auth/github/authorize?state=admin_login`; + }; + + const handleKeySubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!key.trim()) { setError('API key is required'); return; } + setAdminToken(key.trim()); + onSuccess(); + }; + + return ( +
+
+
+ + SolFoundry + +

Admin Dashboard

+
+ + {!showKeyForm ? ( + <> + + +
+ +
+ + ) : ( +
+
+ + { 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 +// --------------------------------------------------------------------------- + +// WS event type → React Query key mapping for cache invalidation +const WS_EVENT_INVALIDATIONS: Record = { + bounty_claimed: [['admin', 'bounties'], ['admin', 'overview']], + bounty_created: [['admin', 'bounties'], ['admin', 'overview']], + bounty_updated: [['admin', 'bounties']], + bounty_closed: [['admin', 'bounties'], ['admin', 'overview']], + pr_submitted: [['admin', 'reviews'], ['admin', 'overview']], + review_complete: [['admin', 'reviews']], + admin_action: [['admin', 'audit-log']], + contributor_banned: [['admin', 'contributors'], ['admin', 'overview']], + payout_completed: [['admin', 'financial'], ['admin', 'overview']], +}; + +export function AdminLayout({ active, onNavigate, children }: Props) { + const [authed, setAuthed] = useState(() => Boolean(getAdminToken())); + const queryClient = useQueryClient(); + + // Invalidate relevant queries on real-time WS events + const handleWsEvent = useCallback( + (event: AdminWsEvent) => { + const keys = WS_EVENT_INVALIDATIONS[event.type]; + if (keys) { + keys.forEach(key => queryClient.invalidateQueries({ queryKey: key })); + } else { + // Unknown event: refresh overview + audit log conservatively + queryClient.invalidateQueries({ queryKey: ['admin', 'overview'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'audit-log'] }); + } + }, + [queryClient], + ); + + const { status: wsStatus } = useAdminWebSocket(handleWsEvent); + + 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..51dfcead --- /dev/null +++ b/frontend/src/components/admin/AuditLogPanel.tsx @@ -0,0 +1,131 @@ +/** 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 + role */} + + {entry.actor} + {entry.role && entry.role !== 'admin' && ( + + {entry.role} + + )} + + + {/* 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..57296ee6 --- /dev/null +++ b/frontend/src/components/admin/BountyManagement.tsx @@ -0,0 +1,393 @@ +/** Bounty management panel — search, filter, create, edit status/reward, close. */ +import { useState } from 'react'; +import { useAdminBounties, useUpdateBounty, useCloseBounty, useCreateBounty } from '../../hooks/useAdminData'; +import type { BountyAdminItem, BountyAdminCreate } 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 */} +
+ + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Create bounty modal +// --------------------------------------------------------------------------- + +function CreateBountyModal({ onClose }: { onClose: () => void }) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [tier, setTier] = useState<1 | 2 | 3>(1); + const [reward, setReward] = useState(''); + const create = useCreateBounty(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const payload: BountyAdminCreate = { + title: title.trim(), + description: description.trim(), + tier, + reward_amount: Number(reward), + }; + await create.mutateAsync(payload); + onClose(); + }; + + return ( +
+
+

Create Bounty

+ +
+ + setTitle(e.target.value)} + placeholder="Bounty title…" + 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="create-title" + /> +
+ +
+ +