Compare commits

...

3 Commits

Author SHA1 Message Date
cacd68ebfb Updates to deployment. 2026-01-11 19:09:52 -06:00
e0801b7f29 Added init endpoint. 2026-01-11 18:57:47 -06:00
a97912188e Added admin panel. 2026-01-11 18:50:26 -06:00
111 changed files with 6761 additions and 253 deletions

View File

@ -1,8 +1,13 @@
from fastapi import FastAPI
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import init_db
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from decimal import Decimal
from app.database import init_db, get_db
from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets, gamification, matches
from app.models import User, Wallet
from app.utils.security import get_password_hash
@asynccontextmanager
@ -50,3 +55,64 @@ async def root():
@app.get("/health")
async def health():
return {"status": "healthy"}
@app.get("/init")
async def init_admin(db: AsyncSession = Depends(get_db)):
"""
Initialize the application with a default admin user.
Only works if no admin users exist in the database.
Creates: admin@test.com / password123
"""
# Check if any admin users exist
result = await db.execute(
select(func.count(User.id)).where(User.is_admin == True)
)
admin_count = result.scalar()
if admin_count > 0:
return {
"success": False,
"message": "Admin user(s) already exist. Initialization skipped.",
"admin_count": admin_count
}
# Check if user with this email already exists
existing = await db.execute(
select(User).where(User.email == "admin@test.com")
)
if existing.scalar_one_or_none():
return {
"success": False,
"message": "User with email admin@test.com already exists but is not an admin."
}
# Create admin user
admin_user = User(
email="admin@test.com",
username="admin",
password_hash=get_password_hash("password123"),
display_name="Administrator",
is_admin=True,
)
db.add(admin_user)
await db.flush()
# Create wallet for admin
wallet = Wallet(
user_id=admin_user.id,
balance=Decimal("10000.00"),
escrow=Decimal("0.00"),
)
db.add(wallet)
await db.commit()
return {
"success": True,
"message": "Admin user created successfully",
"credentials": {
"email": "admin@test.com",
"password": "password123"
}
}

View File

@ -5,6 +5,7 @@ from app.models.bet import Bet, BetProposal, BetCategory, BetStatus, BetVisibili
from app.models.sport_event import SportEvent, SportType, EventStatus
from app.models.spread_bet import SpreadBet, SpreadBetStatus, TeamSide
from app.models.admin_settings import AdminSettings
from app.models.admin_audit_log import AdminAuditLog
from app.models.match_comment import MatchComment
from app.models.event_comment import EventComment
from app.models.gamification import (
@ -40,6 +41,7 @@ __all__ = [
"SpreadBetStatus",
"TeamSide",
"AdminSettings",
"AdminAuditLog",
"MatchComment",
"EventComment",
# Gamification

View File

@ -0,0 +1,54 @@
from sqlalchemy import String, DateTime, Integer, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from app.database import Base
class AdminAuditLog(Base):
"""
Audit log for tracking all admin actions on the platform.
Every admin action should be logged for accountability and debugging.
"""
__tablename__ = "admin_audit_logs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Who performed the action
admin_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
admin_username: Mapped[str] = mapped_column(String(50), nullable=False)
# What action was performed
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Action codes:
# - DATA_WIPE: Database wipe executed
# - DATA_SEED: Database seeded with test data
# - SIMULATION_START: Activity simulation started
# - SIMULATION_STOP: Activity simulation stopped
# - USER_STATUS_CHANGE: User enabled/disabled
# - USER_BALANCE_ADJUST: User balance adjusted
# - USER_ADMIN_GRANT: Admin privileges granted
# - USER_ADMIN_REVOKE: Admin privileges revoked
# - USER_UPDATE: User details updated
# - SETTINGS_UPDATE: Platform settings changed
# - EVENT_CREATE: Sport event created
# - EVENT_UPDATE: Sport event updated
# - EVENT_DELETE: Sport event deleted
# Target of the action (if applicable)
target_type: Mapped[str | None] = mapped_column(String(50), nullable=True) # e.g., "user", "event", "bet"
target_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Description of the action
description: Mapped[str] = mapped_column(String(500), nullable=False)
# Additional details as JSON string
details: Mapped[str | None] = mapped_column(Text, nullable=True)
# IP address of the admin (for security)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
# Timestamp
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
# Relationship to admin user
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])

View File

@ -15,6 +15,8 @@ class TransactionType(enum.Enum):
BET_CANCELLED = "bet_cancelled"
ESCROW_LOCK = "escrow_lock"
ESCROW_RELEASE = "escrow_release"
ADMIN_CREDIT = "admin_credit"
ADMIN_DEBIT = "admin_debit"
class TransactionStatus(enum.Enum):

View File

@ -1,19 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import List
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from typing import Optional
from datetime import datetime
from decimal import Decimal
from app.database import get_db
from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus
from app.models import (
User, UserStatus, SportEvent, SpreadBet, AdminSettings, EventStatus,
Wallet, Transaction, TransactionType, TransactionStatus, AdminAuditLog, Bet
)
from app.schemas.sport_event import SportEventCreate, SportEventUpdate, SportEvent as SportEventSchema
from app.schemas.admin import (
AuditLogResponse, AuditLogListResponse,
WipePreviewResponse, WipeRequest, WipeResponse,
SeedRequest, SeedResponse,
SimulationConfig, SimulationStatusResponse, SimulationStartRequest, SimulationStartResponse, SimulationStopResponse,
AdminUserListItem, AdminUserListResponse, AdminUserDetailResponse,
AdminUserUpdateRequest, AdminUserStatusRequest,
AdminBalanceAdjustRequest, AdminBalanceAdjustResponse,
AdminDashboardStats,
)
from app.routers.auth import get_current_user
from app.services.audit_service import (
AuditService, log_event_create, log_event_update, log_event_delete,
log_user_status_change, log_user_balance_adjust, log_user_admin_change,
log_user_update, log_settings_update, log_simulation_start, log_simulation_stop
)
from app.services.wiper_service import WiperService
from app.services.seeder_service import SeederService
from app.services.simulation_service import simulation_manager
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# Dependency to check if user is admin
# ============================================================
# Helper to get client IP
# ============================================================
def get_client_ip(request: Request) -> Optional[str]:
"""Extract client IP from request."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
# ============================================================
# Admin Dependency
# ============================================================
async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(
@ -23,6 +58,506 @@ async def get_admin_user(current_user: User = Depends(get_current_user)) -> User
return current_user
# ============================================================
# Dashboard Stats
# ============================================================
@router.get("/dashboard", response_model=AdminDashboardStats)
async def get_dashboard_stats(
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get dashboard statistics for admin panel."""
# User counts
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
active_users = (await db.execute(
select(func.count(User.id)).where(User.status == UserStatus.ACTIVE)
)).scalar() or 0
suspended_users = (await db.execute(
select(func.count(User.id)).where(User.status == UserStatus.SUSPENDED)
)).scalar() or 0
admin_users = (await db.execute(
select(func.count(User.id)).where(User.is_admin == True)
)).scalar() or 0
# Event counts
total_events = (await db.execute(select(func.count(SportEvent.id)))).scalar() or 0
upcoming_events = (await db.execute(
select(func.count(SportEvent.id)).where(SportEvent.status == EventStatus.UPCOMING)
)).scalar() or 0
live_events = (await db.execute(
select(func.count(SportEvent.id)).where(SportEvent.status == EventStatus.LIVE)
)).scalar() or 0
# Bet counts
total_bets = (await db.execute(select(func.count(SpreadBet.id)))).scalar() or 0
open_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(SpreadBet.status == "open")
)).scalar() or 0
matched_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(SpreadBet.status == "matched")
)).scalar() or 0
# Volume calculations
total_volume_result = await db.execute(
select(func.sum(SpreadBet.stake_amount))
)
total_volume = total_volume_result.scalar() or Decimal("0.00")
escrow_result = await db.execute(select(func.sum(Wallet.escrow)))
escrow_locked = escrow_result.scalar() or Decimal("0.00")
return AdminDashboardStats(
total_users=total_users,
active_users=active_users,
suspended_users=suspended_users,
admin_users=admin_users,
total_events=total_events,
upcoming_events=upcoming_events,
live_events=live_events,
total_bets=total_bets,
open_bets=open_bets,
matched_bets=matched_bets,
total_volume=total_volume,
escrow_locked=escrow_locked,
simulation_running=simulation_manager.is_running,
)
# ============================================================
# Audit Logs
# ============================================================
@router.get("/audit-logs", response_model=AuditLogListResponse)
async def get_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
action: Optional[str] = None,
admin_id: Optional[int] = None,
target_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get paginated audit logs with optional filters."""
logs, total = await AuditService.get_logs(
db=db,
page=page,
page_size=page_size,
action_filter=action,
admin_id_filter=admin_id,
target_type_filter=target_type,
)
return AuditLogListResponse(
logs=[AuditLogResponse.model_validate(log) for log in logs],
total=total,
page=page,
page_size=page_size,
)
# ============================================================
# Data Wiper
# ============================================================
@router.get("/data/wipe/preview", response_model=WipePreviewResponse)
async def preview_wipe(
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Preview what will be deleted in a wipe operation."""
return await WiperService.get_preview(db)
@router.post("/data/wipe", response_model=WipeResponse)
async def execute_wipe(
request: WipeRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Execute a database wipe. Requires confirmation phrase."""
try:
ip = get_client_ip(req) if req else None
return await WiperService.execute_wipe(db, admin, request, ip)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================================
# Data Seeder
# ============================================================
@router.post("/data/seed", response_model=SeedResponse)
async def seed_database(
request: SeedRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Seed the database with test data."""
ip = get_client_ip(req) if req else None
return await SeederService.seed(db, admin, request, ip)
# ============================================================
# Simulation Control
# ============================================================
@router.get("/simulation/status", response_model=SimulationStatusResponse)
async def get_simulation_status(
admin: User = Depends(get_admin_user)
):
"""Get current simulation status."""
return simulation_manager.get_status()
@router.post("/simulation/start", response_model=SimulationStartResponse)
async def start_simulation(
request: SimulationStartRequest = None,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Start the activity simulation."""
config = request.config if request else None
if simulation_manager.is_running:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation is already running"
)
success = await simulation_manager.start(admin.username, config)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start simulation"
)
# Log the action
ip = get_client_ip(req) if req else None
await log_simulation_start(
db=db,
admin=admin,
config=config.model_dump() if config else {},
ip_address=ip,
)
await db.commit()
return SimulationStartResponse(
success=True,
message="Simulation started successfully",
status=simulation_manager.get_status(),
)
@router.post("/simulation/stop", response_model=SimulationStopResponse)
async def stop_simulation(
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Stop the activity simulation."""
if not simulation_manager.is_running:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation is not running"
)
iterations, duration = await simulation_manager.stop()
# Log the action
ip = get_client_ip(req) if req else None
await log_simulation_stop(
db=db,
admin=admin,
iterations=iterations,
duration_seconds=duration,
ip_address=ip,
)
await db.commit()
return SimulationStopResponse(
success=True,
message="Simulation stopped successfully",
total_iterations=iterations,
ran_for_seconds=duration,
)
# ============================================================
# User Management
# ============================================================
@router.get("/users", response_model=AdminUserListResponse)
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
status_filter: Optional[str] = None,
is_admin: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get paginated list of users."""
query = select(User).options(selectinload(User.wallet))
# Apply filters
if search:
search_term = f"%{search}%"
query = query.where(
(User.username.ilike(search_term)) |
(User.email.ilike(search_term)) |
(User.display_name.ilike(search_term))
)
if status_filter:
query = query.where(User.status == UserStatus(status_filter))
if is_admin is not None:
query = query.where(User.is_admin == is_admin)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar() or 0
# Apply pagination
query = query.order_by(User.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
users = result.scalars().all()
user_items = []
for user in users:
wallet = user.wallet
user_items.append(AdminUserListItem(
id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
is_admin=user.is_admin,
status=user.status.value,
balance=wallet.balance if wallet else Decimal("0.00"),
escrow=wallet.escrow if wallet else Decimal("0.00"),
total_bets=user.total_bets,
wins=user.wins,
losses=user.losses,
win_rate=user.win_rate,
created_at=user.created_at,
))
return AdminUserListResponse(
users=user_items,
total=total,
page=page,
page_size=page_size,
)
@router.get("/users/{user_id}", response_model=AdminUserDetailResponse)
async def get_user_detail(
user_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get detailed user information."""
result = await db.execute(
select(User).options(selectinload(User.wallet)).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
wallet = user.wallet
# Get additional counts
open_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(
(SpreadBet.creator_id == user_id) & (SpreadBet.status == "open")
)
)).scalar() or 0
matched_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(
((SpreadBet.creator_id == user_id) | (SpreadBet.taker_id == user_id)) &
(SpreadBet.status == "matched")
)
)).scalar() or 0
transaction_count = (await db.execute(
select(func.count(Transaction.id)).where(Transaction.user_id == user_id)
)).scalar() or 0
return AdminUserDetailResponse(
id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
avatar_url=user.avatar_url,
bio=user.bio,
is_admin=user.is_admin,
status=user.status.value,
created_at=user.created_at,
updated_at=user.updated_at,
balance=wallet.balance if wallet else Decimal("0.00"),
escrow=wallet.escrow if wallet else Decimal("0.00"),
total_bets=user.total_bets,
wins=user.wins,
losses=user.losses,
win_rate=user.win_rate,
open_bets_count=open_bets,
matched_bets_count=matched_bets,
transaction_count=transaction_count,
)
@router.patch("/users/{user_id}")
async def update_user(
user_id: int,
request: AdminUserUpdateRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Update user details."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
changes = {}
ip = get_client_ip(req) if req else None
if request.display_name is not None and request.display_name != user.display_name:
changes["display_name"] = {"old": user.display_name, "new": request.display_name}
user.display_name = request.display_name
if request.email is not None and request.email != user.email:
# Check if email already exists
existing = await db.execute(select(User).where(User.email == request.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already in use")
changes["email"] = {"old": user.email, "new": request.email}
user.email = request.email
if request.is_admin is not None and request.is_admin != user.is_admin:
# Cannot remove own admin status
if user.id == admin.id and not request.is_admin:
raise HTTPException(status_code=400, detail="Cannot remove your own admin privileges")
await log_user_admin_change(db, admin, user, request.is_admin, ip)
changes["is_admin"] = {"old": user.is_admin, "new": request.is_admin}
user.is_admin = request.is_admin
if changes:
await log_user_update(db, admin, user, changes, ip)
await db.commit()
return {"message": "User updated successfully", "changes": changes}
@router.patch("/users/{user_id}/status")
async def change_user_status(
user_id: int,
request: AdminUserStatusRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Enable or disable a user."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot suspend yourself
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own status")
old_status = user.status.value
new_status = UserStatus(request.status)
if user.status == new_status:
return {"message": "Status unchanged"}
user.status = new_status
ip = get_client_ip(req) if req else None
await log_user_status_change(db, admin, user, old_status, request.status, request.reason, ip)
await db.commit()
return {"message": f"User status changed to {request.status}"}
@router.post("/users/{user_id}/balance", response_model=AdminBalanceAdjustResponse)
async def adjust_user_balance(
user_id: int,
request: AdminBalanceAdjustRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Adjust user balance (add or subtract funds)."""
result = await db.execute(
select(User).options(selectinload(User.wallet)).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
wallet = user.wallet
if not wallet:
raise HTTPException(status_code=400, detail="User has no wallet")
previous_balance = wallet.balance
new_balance = previous_balance + request.amount
# Validate new balance
if new_balance < Decimal("0.00"):
raise HTTPException(
status_code=400,
detail=f"Cannot reduce balance below $0. Current: ${previous_balance}, Adjustment: ${request.amount}"
)
if new_balance < wallet.escrow:
raise HTTPException(
status_code=400,
detail=f"Cannot reduce balance below escrow amount (${wallet.escrow})"
)
wallet.balance = new_balance
# Create transaction record
tx_type = TransactionType.ADMIN_CREDIT if request.amount > 0 else TransactionType.ADMIN_DEBIT
transaction = Transaction(
user_id=user.id,
wallet_id=wallet.id,
type=tx_type,
amount=request.amount,
balance_after=new_balance,
description=f"Admin adjustment: {request.reason}",
status=TransactionStatus.COMPLETED,
)
db.add(transaction)
await db.flush()
ip = get_client_ip(req) if req else None
await log_user_balance_adjust(
db, admin, user,
float(previous_balance), float(new_balance), float(request.amount),
request.reason, transaction.id, ip
)
await db.commit()
return AdminBalanceAdjustResponse(
success=True,
user_id=user.id,
username=user.username,
previous_balance=previous_balance,
adjustment=request.amount,
new_balance=new_balance,
reason=request.reason,
transaction_id=transaction.id,
)
# ============================================================
# Settings Management (existing endpoints, enhanced with audit)
# ============================================================
@router.get("/settings")
async def get_admin_settings(
db: AsyncSession = Depends(get_db),
@ -42,7 +577,8 @@ async def get_admin_settings(
async def update_admin_settings(
updates: dict,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
result = await db.execute(select(AdminSettings).limit(1))
settings = result.scalar_one_or_none()
@ -50,32 +586,49 @@ async def update_admin_settings(
settings = AdminSettings()
db.add(settings)
changes = {}
for key, value in updates.items():
if hasattr(settings, key):
setattr(settings, key, value)
old_value = getattr(settings, key)
if old_value != value:
changes[key] = {"old": str(old_value), "new": str(value)}
setattr(settings, key, value)
if changes:
ip = get_client_ip(req) if req else None
await log_settings_update(db, admin, changes, ip)
await db.commit()
await db.refresh(settings)
return settings
# ============================================================
# Event Management (existing endpoints, enhanced with audit)
# ============================================================
@router.post("/events", response_model=SportEventSchema)
async def create_event(
event_data: SportEventCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
event = SportEvent(
**event_data.model_dump(),
created_by=admin.id
)
db.add(event)
await db.flush()
ip = get_client_ip(req) if req else None
await log_event_create(db, admin, event.id, f"{event.home_team} vs {event.away_team}", ip)
await db.commit()
await db.refresh(event)
return event
@router.get("/events", response_model=List[SportEventSchema])
@router.get("/events", response_model=list[SportEventSchema])
async def list_events(
skip: int = 0,
limit: int = 50,
@ -93,7 +646,8 @@ async def update_event(
event_id: int,
updates: SportEventUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
result = await db.execute(select(SportEvent).where(SportEvent.id == event_id))
event = result.scalar_one_or_none()
@ -101,8 +655,16 @@ async def update_event(
raise HTTPException(status_code=404, detail="Event not found")
update_data = updates.model_dump(exclude_unset=True)
changes = {}
for key, value in update_data.items():
setattr(event, key, value)
old_value = getattr(event, key)
if old_value != value:
changes[key] = {"old": str(old_value), "new": str(value)}
setattr(event, key, value)
if changes:
ip = get_client_ip(req) if req else None
await log_event_update(db, admin, event.id, f"{event.home_team} vs {event.away_team}", changes, ip)
await db.commit()
await db.refresh(event)
@ -113,7 +675,8 @@ async def update_event(
async def delete_event(
event_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
result = await db.execute(select(SportEvent).where(SportEvent.id == event_id))
event = result.scalar_one_or_none()
@ -133,6 +696,11 @@ async def delete_event(
detail="Cannot delete event with matched bets"
)
event_title = f"{event.home_team} vs {event.away_team}"
ip = get_client_ip(req) if req else None
await log_event_delete(db, admin, event_id, event_title, ip)
await db.delete(event)
await db.commit()
return {"message": "Event deleted"}

View File

@ -0,0 +1,265 @@
from pydantic import BaseModel, Field
from datetime import datetime
from decimal import Decimal
from typing import Optional, Literal
from enum import Enum
# ============================================================
# Audit Log Schemas
# ============================================================
class AuditLogAction(str, Enum):
DATA_WIPE = "DATA_WIPE"
DATA_SEED = "DATA_SEED"
SIMULATION_START = "SIMULATION_START"
SIMULATION_STOP = "SIMULATION_STOP"
USER_STATUS_CHANGE = "USER_STATUS_CHANGE"
USER_BALANCE_ADJUST = "USER_BALANCE_ADJUST"
USER_ADMIN_GRANT = "USER_ADMIN_GRANT"
USER_ADMIN_REVOKE = "USER_ADMIN_REVOKE"
USER_UPDATE = "USER_UPDATE"
SETTINGS_UPDATE = "SETTINGS_UPDATE"
EVENT_CREATE = "EVENT_CREATE"
EVENT_UPDATE = "EVENT_UPDATE"
EVENT_DELETE = "EVENT_DELETE"
class AuditLogResponse(BaseModel):
id: int
admin_id: int
admin_username: str
action: str
target_type: Optional[str] = None
target_id: Optional[int] = None
description: str
details: Optional[str] = None
ip_address: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class AuditLogListResponse(BaseModel):
logs: list[AuditLogResponse]
total: int
page: int
page_size: int
# ============================================================
# Data Wiper Schemas
# ============================================================
class WipePreviewResponse(BaseModel):
"""Preview of what will be deleted in a wipe operation."""
users_count: int
wallets_count: int
transactions_count: int
bets_count: int
spread_bets_count: int
events_count: int
event_comments_count: int
match_comments_count: int
admin_settings_preserved: bool = True
admin_users_preserved: bool = True
can_wipe: bool = True
cooldown_remaining_seconds: int = 0
last_wipe_at: Optional[datetime] = None
class WipeRequest(BaseModel):
"""Request to execute a data wipe."""
confirmation_phrase: str = Field(..., description="Must be exactly 'CONFIRM WIPE'")
preserve_admin_users: bool = Field(default=True, description="Keep admin users and their wallets")
preserve_events: bool = Field(default=False, description="Keep sport events but delete bets")
class WipeResponse(BaseModel):
"""Response after a successful wipe operation."""
success: bool
message: str
deleted_counts: dict[str, int]
preserved_counts: dict[str, int]
executed_at: datetime
executed_by: str
# ============================================================
# Data Seeder Schemas
# ============================================================
class SeedRequest(BaseModel):
"""Request to seed the database with test data."""
num_users: int = Field(default=10, ge=1, le=100, description="Number of users to create")
num_events: int = Field(default=5, ge=0, le=50, description="Number of sport events to create")
num_bets_per_event: int = Field(default=3, ge=0, le=20, description="Average bets per event")
starting_balance: Decimal = Field(default=Decimal("1000.00"), ge=Decimal("100"), le=Decimal("10000"))
create_admin: bool = Field(default=True, description="Create a test admin user")
class SeedResponse(BaseModel):
"""Response after seeding the database."""
success: bool
message: str
created_counts: dict[str, int]
test_admin: Optional[dict] = None # Contains username/password if admin created
# ============================================================
# Simulation Schemas
# ============================================================
class SimulationConfig(BaseModel):
"""Configuration for activity simulation."""
delay_seconds: float = Field(default=2.0, ge=0.5, le=30.0, description="Delay between actions")
actions_per_iteration: int = Field(default=3, ge=1, le=10, description="Actions per iteration")
create_users: bool = Field(default=True, description="Allow creating new users")
create_bets: bool = Field(default=True, description="Allow creating bets")
take_bets: bool = Field(default=True, description="Allow taking/matching bets")
add_comments: bool = Field(default=True, description="Allow adding comments")
cancel_bets: bool = Field(default=True, description="Allow cancelling bets")
class SimulationStatusResponse(BaseModel):
"""Response with current simulation status."""
is_running: bool
started_at: Optional[datetime] = None
started_by: Optional[str] = None
iterations_completed: int = 0
config: Optional[SimulationConfig] = None
last_activity: Optional[str] = None
class SimulationStartRequest(BaseModel):
"""Request to start simulation."""
config: Optional[SimulationConfig] = None
class SimulationStartResponse(BaseModel):
"""Response after starting simulation."""
success: bool
message: str
status: SimulationStatusResponse
class SimulationStopResponse(BaseModel):
"""Response after stopping simulation."""
success: bool
message: str
total_iterations: int
ran_for_seconds: float
# ============================================================
# User Management Schemas
# ============================================================
class AdminUserListItem(BaseModel):
"""User item in admin user list."""
id: int
email: str
username: str
display_name: Optional[str] = None
is_admin: bool
status: str
balance: Decimal
escrow: Decimal
total_bets: int
wins: int
losses: int
win_rate: float
created_at: datetime
class Config:
from_attributes = True
class AdminUserListResponse(BaseModel):
"""Paginated list of users."""
users: list[AdminUserListItem]
total: int
page: int
page_size: int
class AdminUserDetailResponse(BaseModel):
"""Detailed user info for admin."""
id: int
email: str
username: str
display_name: Optional[str] = None
avatar_url: Optional[str] = None
bio: Optional[str] = None
is_admin: bool
status: str
created_at: datetime
updated_at: datetime
# Wallet info
balance: Decimal
escrow: Decimal
# Stats
total_bets: int
wins: int
losses: int
win_rate: float
# Counts
open_bets_count: int
matched_bets_count: int
transaction_count: int
class Config:
from_attributes = True
class AdminUserUpdateRequest(BaseModel):
"""Request to update user details."""
display_name: Optional[str] = None
email: Optional[str] = None
is_admin: Optional[bool] = None
class AdminUserStatusRequest(BaseModel):
"""Request to change user status."""
status: Literal["active", "suspended"]
reason: Optional[str] = None
class AdminBalanceAdjustRequest(BaseModel):
"""Request to adjust user balance."""
amount: Decimal = Field(..., description="Positive to add, negative to subtract")
reason: str = Field(..., min_length=5, max_length=500, description="Reason for adjustment")
class AdminBalanceAdjustResponse(BaseModel):
"""Response after balance adjustment."""
success: bool
user_id: int
username: str
previous_balance: Decimal
adjustment: Decimal
new_balance: Decimal
reason: str
transaction_id: int
# ============================================================
# Admin Dashboard Stats
# ============================================================
class AdminDashboardStats(BaseModel):
"""Dashboard statistics for admin panel."""
total_users: int
active_users: int
suspended_users: int
admin_users: int
total_events: int
upcoming_events: int
live_events: int
total_bets: int
open_bets: int
matched_bets: int
total_volume: Decimal
escrow_locked: Decimal
simulation_running: bool

View File

@ -42,6 +42,7 @@ class UserResponse(BaseModel):
losses: int
win_rate: float
status: UserStatus
is_admin: bool = False
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,370 @@
"""
Audit Service for logging admin actions.
All admin operations should call this service to create audit trails.
"""
import json
from typing import Optional, Any
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.models.admin_audit_log import AdminAuditLog
from app.models import User
class AuditService:
"""Service for creating and querying audit logs."""
@staticmethod
async def log(
db: AsyncSession,
admin: User,
action: str,
description: str,
target_type: Optional[str] = None,
target_id: Optional[int] = None,
details: Optional[dict[str, Any]] = None,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""
Create an audit log entry.
Args:
db: Database session
admin: The admin user performing the action
action: Action code (e.g., DATA_WIPE, USER_UPDATE)
description: Human-readable description
target_type: Type of target (user, event, bet, etc.)
target_id: ID of the target entity
details: Additional details as a dictionary
ip_address: IP address of the admin
Returns:
The created audit log entry
"""
log_entry = AdminAuditLog(
admin_id=admin.id,
admin_username=admin.username,
action=action,
target_type=target_type,
target_id=target_id,
description=description,
details=json.dumps(details) if details else None,
ip_address=ip_address,
)
db.add(log_entry)
await db.flush()
return log_entry
@staticmethod
async def get_logs(
db: AsyncSession,
page: int = 1,
page_size: int = 50,
action_filter: Optional[str] = None,
admin_id_filter: Optional[int] = None,
target_type_filter: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
) -> tuple[list[AdminAuditLog], int]:
"""
Get paginated audit logs with optional filters.
Returns:
Tuple of (logs, total_count)
"""
query = select(AdminAuditLog)
# Apply filters
if action_filter:
query = query.where(AdminAuditLog.action == action_filter)
if admin_id_filter:
query = query.where(AdminAuditLog.admin_id == admin_id_filter)
if target_type_filter:
query = query.where(AdminAuditLog.target_type == target_type_filter)
if start_date:
query = query.where(AdminAuditLog.created_at >= start_date)
if end_date:
query = query.where(AdminAuditLog.created_at <= end_date)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Apply pagination and ordering
query = query.order_by(AdminAuditLog.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
logs = list(result.scalars().all())
return logs, total
@staticmethod
async def get_latest_action(
db: AsyncSession,
action: str,
) -> Optional[AdminAuditLog]:
"""Get the most recent log entry for a specific action."""
query = (
select(AdminAuditLog)
.where(AdminAuditLog.action == action)
.order_by(AdminAuditLog.created_at.desc())
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none()
# Convenience functions for common audit actions
async def log_data_wipe(
db: AsyncSession,
admin: User,
deleted_counts: dict[str, int],
preserved_counts: dict[str, int],
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log a data wipe operation."""
return await AuditService.log(
db=db,
admin=admin,
action="DATA_WIPE",
description=f"Database wiped by {admin.username}",
details={
"deleted": deleted_counts,
"preserved": preserved_counts,
},
ip_address=ip_address,
)
async def log_data_seed(
db: AsyncSession,
admin: User,
created_counts: dict[str, int],
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log a data seed operation."""
return await AuditService.log(
db=db,
admin=admin,
action="DATA_SEED",
description=f"Database seeded by {admin.username}",
details={"created": created_counts},
ip_address=ip_address,
)
async def log_simulation_start(
db: AsyncSession,
admin: User,
config: dict,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log simulation start."""
return await AuditService.log(
db=db,
admin=admin,
action="SIMULATION_START",
description=f"Simulation started by {admin.username}",
details={"config": config},
ip_address=ip_address,
)
async def log_simulation_stop(
db: AsyncSession,
admin: User,
iterations: int,
duration_seconds: float,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log simulation stop."""
return await AuditService.log(
db=db,
admin=admin,
action="SIMULATION_STOP",
description=f"Simulation stopped by {admin.username} after {iterations} iterations",
details={
"iterations": iterations,
"duration_seconds": duration_seconds,
},
ip_address=ip_address,
)
async def log_user_status_change(
db: AsyncSession,
admin: User,
target_user: User,
old_status: str,
new_status: str,
reason: Optional[str] = None,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log user status change."""
return await AuditService.log(
db=db,
admin=admin,
action="USER_STATUS_CHANGE",
description=f"{admin.username} changed {target_user.username}'s status from {old_status} to {new_status}",
target_type="user",
target_id=target_user.id,
details={
"old_status": old_status,
"new_status": new_status,
"reason": reason,
},
ip_address=ip_address,
)
async def log_user_balance_adjust(
db: AsyncSession,
admin: User,
target_user: User,
old_balance: float,
new_balance: float,
amount: float,
reason: str,
transaction_id: int,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log user balance adjustment."""
return await AuditService.log(
db=db,
admin=admin,
action="USER_BALANCE_ADJUST",
description=f"{admin.username} adjusted {target_user.username}'s balance by ${amount:+.2f}",
target_type="user",
target_id=target_user.id,
details={
"old_balance": old_balance,
"new_balance": new_balance,
"amount": amount,
"reason": reason,
"transaction_id": transaction_id,
},
ip_address=ip_address,
)
async def log_user_admin_change(
db: AsyncSession,
admin: User,
target_user: User,
granted: bool,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log admin privilege grant/revoke."""
action = "USER_ADMIN_GRANT" if granted else "USER_ADMIN_REVOKE"
verb = "granted admin privileges to" if granted else "revoked admin privileges from"
return await AuditService.log(
db=db,
admin=admin,
action=action,
description=f"{admin.username} {verb} {target_user.username}",
target_type="user",
target_id=target_user.id,
details={"granted": granted},
ip_address=ip_address,
)
async def log_user_update(
db: AsyncSession,
admin: User,
target_user: User,
changes: dict[str, Any],
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log user details update."""
return await AuditService.log(
db=db,
admin=admin,
action="USER_UPDATE",
description=f"{admin.username} updated {target_user.username}'s profile",
target_type="user",
target_id=target_user.id,
details={"changes": changes},
ip_address=ip_address,
)
async def log_settings_update(
db: AsyncSession,
admin: User,
changes: dict[str, Any],
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log platform settings update."""
return await AuditService.log(
db=db,
admin=admin,
action="SETTINGS_UPDATE",
description=f"{admin.username} updated platform settings",
details={"changes": changes},
ip_address=ip_address,
)
async def log_event_create(
db: AsyncSession,
admin: User,
event_id: int,
event_title: str,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log sport event creation."""
return await AuditService.log(
db=db,
admin=admin,
action="EVENT_CREATE",
description=f"{admin.username} created event: {event_title}",
target_type="event",
target_id=event_id,
ip_address=ip_address,
)
async def log_event_update(
db: AsyncSession,
admin: User,
event_id: int,
event_title: str,
changes: dict[str, Any],
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log sport event update."""
return await AuditService.log(
db=db,
admin=admin,
action="EVENT_UPDATE",
description=f"{admin.username} updated event: {event_title}",
target_type="event",
target_id=event_id,
details={"changes": changes},
ip_address=ip_address,
)
async def log_event_delete(
db: AsyncSession,
admin: User,
event_id: int,
event_title: str,
ip_address: Optional[str] = None,
) -> AdminAuditLog:
"""Log sport event deletion."""
return await AuditService.log(
db=db,
admin=admin,
action="EVENT_DELETE",
description=f"{admin.username} deleted event: {event_title}",
target_type="event",
target_id=event_id,
ip_address=ip_address,
)

View File

@ -0,0 +1,262 @@
"""
Data Seeder Service for populating the database with test data.
Can be controlled via API for admin-initiated seeding.
"""
import random
from decimal import Decimal
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models import (
User, Wallet, SportEvent, SpreadBet, EventComment,
SportType, EventStatus, SpreadBetStatus, TeamSide
)
from app.schemas.admin import SeedRequest, SeedResponse
from app.services.audit_service import log_data_seed
from app.utils.security import get_password_hash
# Sample data for generating random content
FIRST_NAMES = [
"James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Oliver", "Sophia",
"Elijah", "Isabella", "Lucas", "Mia", "Mason", "Charlotte", "Ethan",
"Amelia", "Alexander", "Harper", "Henry", "Evelyn", "Sebastian", "Luna",
"Jack", "Camila", "Aiden", "Gianna", "Owen", "Abigail", "Samuel", "Ella",
]
LAST_NAMES = [
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
"Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez",
"Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin",
]
NFL_TEAMS = [
("Kansas City Chiefs", "Arrowhead Stadium"),
("San Francisco 49ers", "Levi's Stadium"),
("Philadelphia Eagles", "Lincoln Financial Field"),
("Dallas Cowboys", "AT&T Stadium"),
("Buffalo Bills", "Highmark Stadium"),
("Miami Dolphins", "Hard Rock Stadium"),
("Detroit Lions", "Ford Field"),
("Green Bay Packers", "Lambeau Field"),
("Baltimore Ravens", "M&T Bank Stadium"),
("Cincinnati Bengals", "Paycor Stadium"),
]
NBA_TEAMS = [
("Boston Celtics", "TD Garden"),
("Denver Nuggets", "Ball Arena"),
("Milwaukee Bucks", "Fiserv Forum"),
("Los Angeles Lakers", "Crypto.com Arena"),
("Phoenix Suns", "Footprint Center"),
("Golden State Warriors", "Chase Center"),
("Miami Heat", "Kaseya Center"),
("Cleveland Cavaliers", "Rocket Mortgage FieldHouse"),
]
EVENT_COMMENTS = [
"This is going to be a great game!",
"Home team looking strong this season",
"I'm betting on the underdog here",
"What do you all think about the spread?",
"Last time these teams played it was close",
"The odds seem off to me",
"Sharp money coming in on the home side",
"Value play on the underdog here",
"Home field advantage is huge here",
"Rivalry game, throw out the records!",
]
class SeederService:
"""Service for seeding the database with test data."""
@staticmethod
async def seed(
db: AsyncSession,
admin: User,
request: SeedRequest,
ip_address: Optional[str] = None,
) -> SeedResponse:
"""
Seed the database with test data.
"""
created_counts = {
"users": 0,
"wallets": 0,
"events": 0,
"bets": 0,
"comments": 0,
}
test_admin_info = None
# Create test admin if requested and doesn't exist
if request.create_admin:
existing_admin = await db.execute(
select(User).where(User.username == "testadmin")
)
if not existing_admin.scalar_one_or_none():
test_admin = User(
email="testadmin@example.com",
username="testadmin",
password_hash=get_password_hash("admin123"),
display_name="Test Administrator",
is_admin=True,
)
db.add(test_admin)
await db.flush()
admin_wallet = Wallet(
user_id=test_admin.id,
balance=Decimal("10000.00"),
escrow=Decimal("0.00"),
)
db.add(admin_wallet)
created_counts["users"] += 1
created_counts["wallets"] += 1
test_admin_info = {
"username": "testadmin",
"email": "testadmin@example.com",
"password": "admin123",
}
# Create regular test users
users = []
for i in range(request.num_users):
first = random.choice(FIRST_NAMES)
last = random.choice(LAST_NAMES)
suffix = random.randint(100, 9999)
username = f"{first.lower()}{last.lower()}{suffix}"
email = f"{username}@example.com"
# Check if user already exists
existing = await db.execute(
select(User).where(User.username == username)
)
if existing.scalar_one_or_none():
continue
user = User(
email=email,
username=username,
password_hash=get_password_hash("password123"),
display_name=f"{first} {last}",
)
db.add(user)
await db.flush()
wallet = Wallet(
user_id=user.id,
balance=request.starting_balance,
escrow=Decimal("0.00"),
)
db.add(wallet)
users.append(user)
created_counts["users"] += 1
created_counts["wallets"] += 1
# Create sport events
events = []
all_teams = [(t, v, SportType.FOOTBALL, "NFL") for t, v in NFL_TEAMS] + \
[(t, v, SportType.BASKETBALL, "NBA") for t, v in NBA_TEAMS]
for i in range(request.num_events):
# Pick two different teams from the same sport
team_pool = random.choice([NFL_TEAMS, NBA_TEAMS])
sport = SportType.FOOTBALL if team_pool == NFL_TEAMS else SportType.BASKETBALL
league = "NFL" if sport == SportType.FOOTBALL else "NBA"
home_team, venue = random.choice(team_pool)
away_team, _ = random.choice([t for t in team_pool if t[0] != home_team])
# Random game time in the next 7 days
game_time = datetime.utcnow() + timedelta(
days=random.randint(1, 7),
hours=random.randint(0, 23),
)
# Random spread
spread = round(random.uniform(-10, 10) * 2) / 2 # Half-point spread
event = SportEvent(
sport=sport,
home_team=home_team,
away_team=away_team,
official_spread=spread,
game_time=game_time,
venue=venue,
league=league,
min_spread=-15.0,
max_spread=15.0,
min_bet_amount=Decimal("10.00"),
max_bet_amount=Decimal("1000.00"),
status=EventStatus.UPCOMING,
created_by=admin.id,
)
db.add(event)
await db.flush()
events.append(event)
created_counts["events"] += 1
# Create bets on events
if users and events:
for event in events:
num_bets = random.randint(1, request.num_bets_per_event * 2)
for _ in range(num_bets):
user = random.choice(users)
# Random spread near official
spread_offset = random.choice([-2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2])
spread = event.official_spread + spread_offset
spread = max(event.min_spread, min(event.max_spread, spread))
# Ensure half-point
spread = round(spread * 2) / 2
if spread % 1 == 0:
spread += 0.5
stake = Decimal(str(random.randint(25, 200)))
team = random.choice([TeamSide.HOME, TeamSide.AWAY])
bet = SpreadBet(
event_id=event.id,
spread=spread,
team=team,
creator_id=user.id,
stake_amount=stake,
house_commission_percent=Decimal("10.00"),
status=SpreadBetStatus.OPEN,
)
db.add(bet)
created_counts["bets"] += 1
# Add some comments
for _ in range(random.randint(0, 3)):
user = random.choice(users)
comment = EventComment(
event_id=event.id,
user_id=user.id,
content=random.choice(EVENT_COMMENTS),
)
db.add(comment)
created_counts["comments"] += 1
# Log the seed operation
await log_data_seed(
db=db,
admin=admin,
created_counts=created_counts,
ip_address=ip_address,
)
await db.commit()
return SeedResponse(
success=True,
message="Database seeded successfully",
created_counts=created_counts,
test_admin=test_admin_info,
)

View File

@ -0,0 +1,382 @@
"""
Simulation Service for running automated activity in the background.
This service manages starting/stopping simulated user activity.
"""
import asyncio
import random
from decimal import Decimal
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import async_session
from app.models import (
User, Wallet, SportEvent, SpreadBet, EventComment, MatchComment,
EventStatus, SpreadBetStatus, TeamSide, Transaction, TransactionType, TransactionStatus
)
from app.schemas.admin import SimulationConfig, SimulationStatusResponse
from app.utils.security import get_password_hash
# Sample data for simulation
FIRST_NAMES = [
"James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Oliver", "Sophia",
"Elijah", "Isabella", "Lucas", "Mia", "Mason", "Charlotte", "Ethan",
]
LAST_NAMES = [
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
"Davis", "Rodriguez", "Martinez",
]
EVENT_COMMENTS = [
"This is going to be a great game!",
"Home team looking strong this season",
"I'm betting on the underdog here",
"What do you all think about the spread?",
"The odds seem off to me",
"Sharp money coming in on the home side",
"Home field advantage is huge here",
]
MATCH_COMMENTS = [
"Good luck!",
"May the best bettor win",
"I'm feeling confident about this one",
"Nice bet, looking forward to the game",
"GL HF",
]
class SimulationManager:
"""
Singleton manager for controlling simulation state.
Runs simulation in a background asyncio task.
"""
_instance: Optional["SimulationManager"] = None
_task: Optional[asyncio.Task] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
self._running = False
self._started_at: Optional[datetime] = None
self._started_by: Optional[str] = None
self._iterations = 0
self._config: Optional[SimulationConfig] = None
self._last_activity: Optional[str] = None
self._stop_event = asyncio.Event()
@property
def is_running(self) -> bool:
return self._running
def get_status(self) -> SimulationStatusResponse:
return SimulationStatusResponse(
is_running=self._running,
started_at=self._started_at,
started_by=self._started_by,
iterations_completed=self._iterations,
config=self._config,
last_activity=self._last_activity,
)
async def start(self, admin_username: str, config: Optional[SimulationConfig] = None) -> bool:
"""Start the simulation in a background task."""
if self._running:
return False
self._config = config or SimulationConfig()
self._running = True
self._started_at = datetime.utcnow()
self._started_by = admin_username
self._iterations = 0
self._stop_event.clear()
# Start background task
SimulationManager._task = asyncio.create_task(self._run_simulation())
return True
async def stop(self) -> tuple[int, float]:
"""Stop the simulation and return stats."""
if not self._running:
return 0, 0.0
self._stop_event.set()
if SimulationManager._task:
try:
await asyncio.wait_for(SimulationManager._task, timeout=5.0)
except asyncio.TimeoutError:
SimulationManager._task.cancel()
iterations = self._iterations
duration = (datetime.utcnow() - self._started_at).total_seconds() if self._started_at else 0
self._running = False
self._started_at = None
self._started_by = None
self._iterations = 0
self._config = None
SimulationManager._task = None
return iterations, duration
async def _run_simulation(self):
"""Main simulation loop."""
while not self._stop_event.is_set():
try:
async with async_session() as db:
# Get existing users and events
users_result = await db.execute(select(User).where(User.is_admin == False))
users = list(users_result.scalars().all())
events_result = await db.execute(
select(SportEvent).where(SportEvent.status == EventStatus.UPCOMING)
)
events = list(events_result.scalars().all())
if not events:
self._last_activity = "No upcoming events - waiting..."
await asyncio.sleep(self._config.delay_seconds)
continue
# Perform random actions based on config
for _ in range(self._config.actions_per_iteration):
if self._stop_event.is_set():
break
action = self._pick_action()
try:
if action == "create_user" and self._config.create_users:
await self._create_user(db)
# Refresh users list
users_result = await db.execute(select(User).where(User.is_admin == False))
users = list(users_result.scalars().all())
elif action == "create_bet" and self._config.create_bets and users and events:
await self._create_bet(db, users, events)
elif action == "take_bet" and self._config.take_bets and users:
await self._take_bet(db, users)
elif action == "add_comment" and self._config.add_comments and users and events:
await self._add_comment(db, users, events)
elif action == "cancel_bet" and self._config.cancel_bets:
await self._cancel_bet(db)
except Exception as e:
self._last_activity = f"Error: {str(e)[:50]}"
self._iterations += 1
await asyncio.sleep(self._config.delay_seconds)
except Exception as e:
self._last_activity = f"Loop error: {str(e)[:50]}"
await asyncio.sleep(self._config.delay_seconds)
def _pick_action(self) -> str:
"""Pick a random action based on weights."""
actions = [
("create_user", 0.15),
("create_bet", 0.35),
("take_bet", 0.25),
("add_comment", 0.20),
("cancel_bet", 0.05),
]
rand = random.random()
cumulative = 0
for action, weight in actions:
cumulative += weight
if rand <= cumulative:
return action
return "create_bet"
async def _create_user(self, db):
"""Create a random user."""
first = random.choice(FIRST_NAMES)
last = random.choice(LAST_NAMES)
suffix = random.randint(100, 9999)
username = f"{first.lower()}{last.lower()}{suffix}"
email = f"{username}@example.com"
# Check if exists
existing = await db.execute(select(User).where(User.username == username))
if existing.scalar_one_or_none():
return
user = User(
email=email,
username=username,
password_hash=get_password_hash("password123"),
display_name=f"{first} {last}",
)
db.add(user)
await db.flush()
wallet = Wallet(
user_id=user.id,
balance=Decimal(str(random.randint(500, 5000))),
escrow=Decimal("0.00"),
)
db.add(wallet)
await db.commit()
self._last_activity = f"Created user: {username}"
async def _create_bet(self, db, users: list[User], events: list[SportEvent]):
"""Create a random bet."""
# Find users with balance
users_with_balance = []
for user in users:
wallet_result = await db.execute(select(Wallet).where(Wallet.user_id == user.id))
wallet = wallet_result.scalar_one_or_none()
if wallet and wallet.balance >= Decimal("10"):
users_with_balance.append((user, wallet))
if not users_with_balance:
return
user, wallet = random.choice(users_with_balance)
event = random.choice(events)
spread = round(random.uniform(event.min_spread, event.max_spread) * 2) / 2
if spread % 1 == 0:
spread += 0.5
max_stake = min(float(wallet.balance) * 0.5, 500)
stake = Decimal(str(round(random.uniform(10, max(10, max_stake)), 2)))
team = random.choice([TeamSide.HOME, TeamSide.AWAY])
bet = SpreadBet(
event_id=event.id,
spread=spread,
team=team,
creator_id=user.id,
stake_amount=stake,
house_commission_percent=Decimal("10.00"),
status=SpreadBetStatus.OPEN,
)
db.add(bet)
await db.commit()
team_name = event.home_team if team == TeamSide.HOME else event.away_team
self._last_activity = f"{user.username} created ${stake} bet on {team_name}"
async def _take_bet(self, db, users: list[User]):
"""Take a random open bet."""
result = await db.execute(
select(SpreadBet)
.options(selectinload(SpreadBet.event), selectinload(SpreadBet.creator))
.where(SpreadBet.status == SpreadBetStatus.OPEN)
)
open_bets = result.scalars().all()
if not open_bets:
return
bet = random.choice(open_bets)
# Find eligible takers
eligible = []
for user in users:
if user.id == bet.creator_id:
continue
wallet_result = await db.execute(select(Wallet).where(Wallet.user_id == user.id))
wallet = wallet_result.scalar_one_or_none()
if wallet and wallet.balance >= bet.stake_amount:
eligible.append((user, wallet))
if not eligible:
return
taker, taker_wallet = random.choice(eligible)
# Get creator wallet
creator_wallet_result = await db.execute(
select(Wallet).where(Wallet.user_id == bet.creator_id)
)
creator_wallet = creator_wallet_result.scalar_one_or_none()
if not creator_wallet or creator_wallet.balance < bet.stake_amount:
return
# Lock funds
creator_wallet.balance -= bet.stake_amount
creator_wallet.escrow += bet.stake_amount
taker_wallet.balance -= bet.stake_amount
taker_wallet.escrow += bet.stake_amount
# Create transactions
creator_tx = Transaction(
user_id=bet.creator_id,
wallet_id=creator_wallet.id,
type=TransactionType.ESCROW_LOCK,
amount=-bet.stake_amount,
balance_after=creator_wallet.balance,
reference_id=bet.id,
description=f"Escrow lock for spread bet #{bet.id}",
status=TransactionStatus.COMPLETED,
)
taker_tx = Transaction(
user_id=taker.id,
wallet_id=taker_wallet.id,
type=TransactionType.ESCROW_LOCK,
amount=-bet.stake_amount,
balance_after=taker_wallet.balance,
reference_id=bet.id,
description=f"Escrow lock for spread bet #{bet.id}",
status=TransactionStatus.COMPLETED,
)
db.add(creator_tx)
db.add(taker_tx)
bet.taker_id = taker.id
bet.status = SpreadBetStatus.MATCHED
bet.matched_at = datetime.utcnow()
await db.commit()
self._last_activity = f"{taker.username} took ${bet.stake_amount} bet"
async def _add_comment(self, db, users: list[User], events: list[SportEvent]):
"""Add a random comment."""
user = random.choice(users)
event = random.choice(events)
content = random.choice(EVENT_COMMENTS)
comment = EventComment(
event_id=event.id,
user_id=user.id,
content=content,
)
db.add(comment)
await db.commit()
self._last_activity = f"{user.username} commented on {event.home_team} vs {event.away_team}"
async def _cancel_bet(self, db):
"""Cancel a random open bet."""
if random.random() > 0.2: # Only 20% chance
return
result = await db.execute(
select(SpreadBet)
.options(selectinload(SpreadBet.creator))
.where(SpreadBet.status == SpreadBetStatus.OPEN)
)
open_bets = result.scalars().all()
if not open_bets:
return
bet = random.choice(open_bets)
bet.status = SpreadBetStatus.CANCELLED
await db.commit()
self._last_activity = f"{bet.creator.username} cancelled ${bet.stake_amount} bet"
# Global instance
simulation_manager = SimulationManager()

View File

@ -0,0 +1,224 @@
"""
Data Wiper Service for safely clearing database data.
Includes safeguards like confirmation phrases and cooldowns.
"""
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from app.models import (
User, Wallet, Transaction, Bet, SpreadBet, SportEvent,
EventComment, MatchComment, AdminSettings, AdminAuditLog,
UserStats, Achievement, UserAchievement, LootBox, ActivityFeed, DailyReward
)
from app.schemas.admin import WipePreviewResponse, WipeRequest, WipeResponse
from app.services.audit_service import log_data_wipe
# Cooldown between wipes (5 minutes)
WIPE_COOLDOWN_SECONDS = 300
CONFIRMATION_PHRASE = "CONFIRM WIPE"
class WiperService:
"""Service for wiping database data with safeguards."""
@staticmethod
async def get_preview(db: AsyncSession) -> WipePreviewResponse:
"""
Get a preview of what would be deleted in a wipe operation.
Also checks cooldown status.
"""
# Count all entities
users_count = (await db.execute(select(func.count(User.id)))).scalar() or 0
admin_users = (await db.execute(
select(func.count(User.id)).where(User.is_admin == True)
)).scalar() or 0
wallets_count = (await db.execute(select(func.count(Wallet.id)))).scalar() or 0
transactions_count = (await db.execute(select(func.count(Transaction.id)))).scalar() or 0
bets_count = (await db.execute(select(func.count(Bet.id)))).scalar() or 0
spread_bets_count = (await db.execute(select(func.count(SpreadBet.id)))).scalar() or 0
events_count = (await db.execute(select(func.count(SportEvent.id)))).scalar() or 0
event_comments_count = (await db.execute(select(func.count(EventComment.id)))).scalar() or 0
match_comments_count = (await db.execute(select(func.count(MatchComment.id)))).scalar() or 0
# Check last wipe time from audit log
last_wipe_log = await db.execute(
select(AdminAuditLog)
.where(AdminAuditLog.action == "DATA_WIPE")
.order_by(AdminAuditLog.created_at.desc())
.limit(1)
)
last_wipe = last_wipe_log.scalar_one_or_none()
cooldown_remaining = 0
can_wipe = True
last_wipe_at = None
if last_wipe:
last_wipe_at = last_wipe.created_at
elapsed = (datetime.utcnow() - last_wipe.created_at).total_seconds()
if elapsed < WIPE_COOLDOWN_SECONDS:
cooldown_remaining = int(WIPE_COOLDOWN_SECONDS - elapsed)
can_wipe = False
return WipePreviewResponse(
users_count=users_count - admin_users, # Non-admin users that would be deleted
wallets_count=wallets_count,
transactions_count=transactions_count,
bets_count=bets_count,
spread_bets_count=spread_bets_count,
events_count=events_count,
event_comments_count=event_comments_count,
match_comments_count=match_comments_count,
admin_settings_preserved=True,
admin_users_preserved=True,
can_wipe=can_wipe,
cooldown_remaining_seconds=cooldown_remaining,
last_wipe_at=last_wipe_at,
)
@staticmethod
async def execute_wipe(
db: AsyncSession,
admin: User,
request: WipeRequest,
ip_address: Optional[str] = None,
) -> WipeResponse:
"""
Execute a database wipe with safeguards.
Raises:
ValueError: If confirmation phrase is wrong or cooldown not elapsed
"""
# Verify confirmation phrase
if request.confirmation_phrase != CONFIRMATION_PHRASE:
raise ValueError(f"Invalid confirmation phrase. Must be exactly '{CONFIRMATION_PHRASE}'")
# Check cooldown
preview = await WiperService.get_preview(db)
if not preview.can_wipe:
raise ValueError(
f"Wipe cooldown in effect. Please wait {preview.cooldown_remaining_seconds} seconds."
)
deleted_counts = {}
preserved_counts = {}
# Get admin user IDs to preserve
admin_user_ids = []
if request.preserve_admin_users:
admin_users_result = await db.execute(
select(User.id).where(User.is_admin == True)
)
admin_user_ids = [row[0] for row in admin_users_result.fetchall()]
preserved_counts["admin_users"] = len(admin_user_ids)
# Delete in order respecting foreign keys:
# 1. Comments (no FK dependencies)
result = await db.execute(delete(MatchComment))
deleted_counts["match_comments"] = result.rowcount
result = await db.execute(delete(EventComment))
deleted_counts["event_comments"] = result.rowcount
# 2. Gamification data
result = await db.execute(delete(ActivityFeed))
deleted_counts["activity_feed"] = result.rowcount
result = await db.execute(delete(DailyReward))
deleted_counts["daily_rewards"] = result.rowcount
result = await db.execute(delete(LootBox))
deleted_counts["loot_boxes"] = result.rowcount
result = await db.execute(delete(UserAchievement))
deleted_counts["user_achievements"] = result.rowcount
result = await db.execute(delete(UserStats))
deleted_counts["user_stats"] = result.rowcount
# 3. Bets
result = await db.execute(delete(SpreadBet))
deleted_counts["spread_bets"] = result.rowcount
result = await db.execute(delete(Bet))
deleted_counts["bets"] = result.rowcount
# 4. Events (only if not preserving)
if not request.preserve_events:
result = await db.execute(delete(SportEvent))
deleted_counts["events"] = result.rowcount
else:
events_count = (await db.execute(select(func.count(SportEvent.id)))).scalar() or 0
preserved_counts["events"] = events_count
# 5. Transactions
if admin_user_ids:
result = await db.execute(
delete(Transaction).where(Transaction.user_id.notin_(admin_user_ids))
)
else:
result = await db.execute(delete(Transaction))
deleted_counts["transactions"] = result.rowcount
# 6. Wallets (preserve admin wallets, but reset them)
if admin_user_ids:
# Delete non-admin wallets
result = await db.execute(
delete(Wallet).where(Wallet.user_id.notin_(admin_user_ids))
)
deleted_counts["wallets"] = result.rowcount
# Reset admin wallets
from decimal import Decimal
admin_wallets = await db.execute(
select(Wallet).where(Wallet.user_id.in_(admin_user_ids))
)
for wallet in admin_wallets.scalars():
wallet.balance = Decimal("1000.00")
wallet.escrow = Decimal("0.00")
preserved_counts["admin_wallets"] = len(admin_user_ids)
else:
result = await db.execute(delete(Wallet))
deleted_counts["wallets"] = result.rowcount
# 7. Users (preserve admins)
if admin_user_ids:
result = await db.execute(
delete(User).where(User.id.notin_(admin_user_ids))
)
# Reset admin user stats
admin_users = await db.execute(
select(User).where(User.id.in_(admin_user_ids))
)
for user in admin_users.scalars():
user.total_bets = 0
user.wins = 0
user.losses = 0
user.win_rate = 0.0
else:
result = await db.execute(delete(User))
deleted_counts["users"] = result.rowcount
# Log the wipe before committing
await log_data_wipe(
db=db,
admin=admin,
deleted_counts=deleted_counts,
preserved_counts=preserved_counts,
ip_address=ip_address,
)
await db.commit()
return WipeResponse(
success=True,
message="Database wiped successfully",
deleted_counts=deleted_counts,
preserved_counts=preserved_counts,
executed_at=datetime.utcnow(),
executed_by=admin.username,
)

View File

@ -0,0 +1,807 @@
# Admin Panel Implementation - Project Plan
## Project Charter
**Project Name:** H2H Admin Panel Enhancement
**Version:** 1.0
**Date:** January 11, 2026
**Project Manager:** [TBD]
### Executive Summary
This project plan outlines the implementation of a comprehensive Admin Panel for the H2H betting platform. The panel will provide administrators with tools for data management, activity simulation, user administration, and audit logging. The implementation leverages the existing FastAPI backend and React frontend infrastructure.
### Project Objectives
1. Enable administrators to reset/wipe database data safely
2. Provide seeding capabilities for test data generation
3. Implement toggleable activity simulation for testing and demos
4. Create comprehensive user management capabilities
5. Establish audit logging for all administrative actions
### Success Criteria
- All five core features fully functional and tested
- Admin actions logged with full traceability
- No data integrity issues during wipe/seed operations
- Simulation can run without impacting production stability
- User management operations complete within 2 seconds
---
## Current State Analysis
### Existing Infrastructure
| Component | Status | Location |
|-----------|--------|----------|
| Admin Router | Partial | `/backend/app/routers/admin.py` |
| Admin Settings Model | Complete | `/backend/app/models/admin_settings.py` |
| Frontend Admin Page | Partial | `/frontend/src/pages/Admin.tsx` |
| Admin API Client | Partial | `/frontend/src/api/admin.ts` |
| User Model (is_admin flag) | Complete | `/backend/app/models/user.py` |
| Simulation Script | Standalone | `/backend/simulate_activity.py` |
| Seed Script | Standalone | `/backend/seed_data.py` |
| Event Manager | Standalone | `/backend/manage_events.py` |
### Current Capabilities
The existing admin router provides:
- Admin settings retrieval and update
- Sport event CRUD operations
- Admin user verification middleware
### Gap Analysis
| Feature | Current State | Required State |
|---------|---------------|----------------|
| Data Wiper | None | Full implementation |
| Data Seeder | CLI script only | API-integrated |
| Simulation Toggle | CLI script only | API-controlled with background task |
| User Management | None | Full CRUD + balance adjustment |
| Audit Log | None | Full implementation |
---
## Work Breakdown Structure (WBS)
```
1.0 Admin Panel Enhancement
|
+-- 1.1 Foundation & Infrastructure
| +-- 1.1.1 Create AdminAuditLog model
| +-- 1.1.2 Add simulation_enabled flag to AdminSettings
| +-- 1.1.3 Create admin schemas for new endpoints
| +-- 1.1.4 Set up background task infrastructure for simulation
| +-- 1.1.5 Update database initialization
|
+-- 1.2 Audit Logging System
| +-- 1.2.1 Design audit log data model
| +-- 1.2.2 Create audit logging utility functions
| +-- 1.2.3 Implement audit log API endpoints
| +-- 1.2.4 Create frontend audit log viewer component
| +-- 1.2.5 Integrate audit logging into admin actions
|
+-- 1.3 Data Wiper Feature
| +-- 1.3.1 Design wipe strategy (selective vs full)
| +-- 1.3.2 Implement wipe endpoints with confirmation
| +-- 1.3.3 Add safeguards (confirmation token, cooldown)
| +-- 1.3.4 Create frontend wipe controls with confirmation modal
| +-- 1.3.5 Integrate audit logging
|
+-- 1.4 Data Seeder Feature
| +-- 1.4.1 Refactor seed_data.py into service module
| +-- 1.4.2 Create configurable seeder options
| +-- 1.4.3 Implement seed API endpoints
| +-- 1.4.4 Create frontend seed controls
| +-- 1.4.5 Integrate audit logging
|
+-- 1.5 Activity Simulation Feature
| +-- 1.5.1 Refactor simulate_activity.py into service module
| +-- 1.5.2 Create simulation manager with start/stop control
| +-- 1.5.3 Implement simulation API endpoints
| +-- 1.5.4 Create simulation status WebSocket events
| +-- 1.5.5 Create frontend simulation toggle and status
| +-- 1.5.6 Integrate audit logging
|
+-- 1.6 User Management Feature
| +-- 1.6.1 Create user management schemas
| +-- 1.6.2 Implement user list/search endpoint
| +-- 1.6.3 Implement user detail/edit endpoint
| +-- 1.6.4 Implement user status toggle (enable/disable)
| +-- 1.6.5 Implement balance adjustment endpoint
| +-- 1.6.6 Create frontend user management table
| +-- 1.6.7 Create user edit modal
| +-- 1.6.8 Create balance adjustment modal
| +-- 1.6.9 Integrate audit logging
|
+-- 1.7 Frontend Integration
| +-- 1.7.1 Redesign Admin.tsx with tabbed interface
| +-- 1.7.2 Create AdminDataTools component (wipe/seed)
| +-- 1.7.3 Create AdminSimulation component
| +-- 1.7.4 Create AdminUsers component
| +-- 1.7.5 Create AdminAuditLog component
| +-- 1.7.6 Update admin API client
| +-- 1.7.7 Add admin route protection
|
+-- 1.8 Testing & Documentation
| +-- 1.8.1 Unit tests for admin services
| +-- 1.8.2 Integration tests for admin endpoints
| +-- 1.8.3 E2E tests for admin UI
| +-- 1.8.4 Update API documentation
| +-- 1.8.5 Create admin user guide
```
---
## Implementation Phases
### Phase 1: Foundation & Audit Infrastructure
**Objective:** Establish the foundational models, schemas, and audit logging system that other features depend on.
#### Tasks
| ID | Task | Dependencies | Files Affected |
|----|------|--------------|----------------|
| 1.1.1 | Create AdminAuditLog model | None | `backend/app/models/admin_audit_log.py`, `backend/app/models/__init__.py` |
| 1.1.2 | Add simulation_enabled to AdminSettings | None | `backend/app/models/admin_settings.py` |
| 1.1.3 | Create admin schemas | 1.1.1 | `backend/app/schemas/admin.py` |
| 1.2.2 | Create audit logging utility | 1.1.1 | `backend/app/services/audit_service.py` |
| 1.2.3 | Implement audit log endpoints | 1.2.2 | `backend/app/routers/admin.py` |
#### New File: `backend/app/models/admin_audit_log.py`
```python
# Model structure
class AdminAuditLog(Base):
__tablename__ = "admin_audit_logs"
id: int (PK)
admin_user_id: int (FK -> users.id)
action: str # e.g., "DATA_WIPE", "USER_DISABLE", "SEED_DATA"
action_category: str # e.g., "data_management", "user_management", "simulation"
target_type: str | None # e.g., "user", "bet", "event"
target_id: int | None
details: dict (JSON) # Additional context
ip_address: str | None
user_agent: str | None
created_at: datetime
```
#### New File: `backend/app/schemas/admin.py`
```python
# Schema structure
class AuditLogEntry(BaseModel):
id: int
admin_user_id: int
admin_username: str
action: str
action_category: str
target_type: str | None
target_id: int | None
details: dict
created_at: datetime
class AuditLogListResponse(BaseModel):
items: list[AuditLogEntry]
total: int
page: int
page_size: int
class DataWipeRequest(BaseModel):
wipe_type: str # "all", "bets", "events", "transactions", "users_except_admin"
confirm_phrase: str # Must match "CONFIRM WIPE"
class SeedDataRequest(BaseModel):
user_count: int = 3
event_count: int = 5
bet_count: int = 10
include_matched_bets: bool = True
class SimulationConfig(BaseModel):
enabled: bool
delay_seconds: float = 2.0
actions_per_iteration: int = 3
class UserListResponse(BaseModel):
items: list[UserAdmin]
total: int
page: int
page_size: int
class UserAdmin(BaseModel):
id: int
email: str
username: str
display_name: str | None
status: str
is_admin: bool
balance: Decimal
escrow: Decimal
total_bets: int
wins: int
losses: int
created_at: datetime
class UserUpdateRequest(BaseModel):
display_name: str | None
status: str | None
is_admin: bool | None
class BalanceAdjustmentRequest(BaseModel):
amount: Decimal
reason: str
```
---
### Phase 2: Data Management (Wiper & Seeder)
**Objective:** Implement safe data wipe and seed capabilities with proper safeguards.
#### Tasks
| ID | Task | Dependencies | Files Affected |
|----|------|--------------|----------------|
| 1.3.1 | Design wipe strategy | 1.1.1 | Design document |
| 1.3.2 | Implement wipe endpoints | 1.3.1, 1.2.2 | `backend/app/routers/admin.py` |
| 1.3.3 | Add safeguards | 1.3.2 | `backend/app/routers/admin.py` |
| 1.4.1 | Refactor seed_data.py | None | `backend/app/services/seeder_service.py` |
| 1.4.3 | Implement seed endpoints | 1.4.1, 1.2.2 | `backend/app/routers/admin.py` |
#### Data Wipe Strategy
| Wipe Type | Tables Affected | Preserved |
|-----------|-----------------|-----------|
| `bets` | Bet, BetProposal, SpreadBet, MatchComment | Users, Wallets (reset balances), Events |
| `events` | SportEvent, EventComment, related SpreadBets | Users, Wallets, generic Bets |
| `transactions` | Transaction | Users, Wallets (reset balances), Bets, Events |
| `users_except_admin` | User, Wallet, all related data | Admin users only |
| `all` | All data except admin users | Admin user accounts |
#### Wipe Safeguards
1. **Confirmation Phrase:** Request body must include `confirm_phrase: "CONFIRM WIPE"`
2. **Rate Limiting:** Maximum 1 wipe per 5 minutes
3. **Audit Logging:** Full details logged before wipe executes
4. **Backup Suggestion:** API response includes reminder to backup
#### New File: `backend/app/services/seeder_service.py`
```python
# Refactored from seed_data.py
class SeederService:
async def seed_users(self, db, count: int) -> list[User]
async def seed_events(self, db, admin_id: int, count: int) -> list[SportEvent]
async def seed_bets(self, db, users: list, events: list, count: int) -> list[SpreadBet]
async def seed_all(self, db, config: SeedDataRequest) -> SeedResult
```
---
### Phase 3: Activity Simulation
**Objective:** Enable real-time control of activity simulation from the admin panel.
#### Tasks
| ID | Task | Dependencies | Files Affected |
|----|------|--------------|----------------|
| 1.5.1 | Refactor simulate_activity.py | None | `backend/app/services/simulation_service.py` |
| 1.5.2 | Create simulation manager | 1.5.1 | `backend/app/services/simulation_manager.py` |
| 1.5.3 | Implement simulation endpoints | 1.5.2, 1.2.2 | `backend/app/routers/admin.py` |
| 1.5.4 | Create WebSocket events | 1.5.2 | `backend/app/routers/websocket.py` |
#### New File: `backend/app/services/simulation_service.py`
```python
# Refactored from simulate_activity.py
class SimulationService:
async def create_random_user(self, db) -> User | None
async def create_random_bet(self, db, users, events) -> SpreadBet | None
async def take_random_bet(self, db, users) -> SpreadBet | None
async def cancel_random_bet(self, db) -> SpreadBet | None
async def add_event_comment(self, db, users, events) -> EventComment | None
async def add_match_comment(self, db, users) -> MatchComment | None
async def run_iteration(self, db) -> SimulationIterationResult
```
#### New File: `backend/app/services/simulation_manager.py`
```python
# Background task manager for simulation
class SimulationManager:
_instance: SimulationManager | None = None
_task: asyncio.Task | None = None
_running: bool = False
_config: SimulationConfig
@classmethod
def get_instance(cls) -> SimulationManager
async def start(self, config: SimulationConfig) -> bool
async def stop(self) -> bool
def is_running(self) -> bool
def get_status(self) -> SimulationStatus
```
#### Simulation API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/admin/simulation/status` | GET | Get current simulation status |
| `/api/v1/admin/simulation/start` | POST | Start simulation with config |
| `/api/v1/admin/simulation/stop` | POST | Stop simulation |
---
### Phase 4: User Management
**Objective:** Provide comprehensive user administration capabilities.
#### Tasks
| ID | Task | Dependencies | Files Affected |
|----|------|--------------|----------------|
| 1.6.1 | Create user management schemas | 1.1.3 | `backend/app/schemas/admin.py` |
| 1.6.2 | Implement user list endpoint | 1.6.1 | `backend/app/routers/admin.py` |
| 1.6.3 | Implement user edit endpoint | 1.6.1, 1.2.2 | `backend/app/routers/admin.py` |
| 1.6.4 | Implement status toggle | 1.6.3 | `backend/app/routers/admin.py` |
| 1.6.5 | Implement balance adjustment | 1.6.3, 1.2.2 | `backend/app/routers/admin.py` |
#### User Management API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/admin/users` | GET | List users with pagination/search |
| `/api/v1/admin/users/{user_id}` | GET | Get user details |
| `/api/v1/admin/users/{user_id}` | PATCH | Update user |
| `/api/v1/admin/users/{user_id}/status` | PATCH | Toggle user status |
| `/api/v1/admin/users/{user_id}/balance` | POST | Adjust balance |
#### Balance Adjustment Rules
1. Adjustment creates a Transaction record with type `ADMIN_ADJUSTMENT`
2. Both positive and negative adjustments allowed
3. Cannot reduce balance below escrow amount
4. Reason field is required and logged
5. Full audit trail maintained
---
### Phase 5: Frontend Implementation
**Objective:** Create a comprehensive admin UI with all features integrated.
#### Tasks
| ID | Task | Dependencies | Files Affected |
|----|------|--------------|----------------|
| 1.7.1 | Redesign Admin.tsx | None | `frontend/src/pages/Admin.tsx` |
| 1.7.2 | Create AdminDataTools | Phase 2 complete | `frontend/src/components/admin/AdminDataTools.tsx` |
| 1.7.3 | Create AdminSimulation | Phase 3 complete | `frontend/src/components/admin/AdminSimulation.tsx` |
| 1.7.4 | Create AdminUsers | Phase 4 complete | `frontend/src/components/admin/AdminUsers.tsx` |
| 1.7.5 | Create AdminAuditLog | Phase 1 complete | `frontend/src/components/admin/AdminAuditLog.tsx` |
| 1.7.6 | Update admin API client | All phases | `frontend/src/api/admin.ts` |
#### New Frontend Files
```
frontend/src/
components/
admin/
AdminDataTools.tsx # Wipe/Seed controls
AdminSimulation.tsx # Simulation toggle and status
AdminUsers.tsx # User table with actions
AdminAuditLog.tsx # Audit log viewer
UserEditModal.tsx # User edit dialog
BalanceAdjustModal.tsx # Balance adjustment dialog
ConfirmWipeModal.tsx # Wipe confirmation dialog
types/
admin.ts # Admin-specific types
```
#### Admin.tsx Tab Structure
```tsx
// Tab structure
<Tabs>
<Tab label="Events"> // Existing functionality
<Tab label="Users"> // User management table
<Tab label="Data Tools"> // Wipe & Seed controls
<Tab label="Simulation"> // Simulation toggle
<Tab label="Audit Log"> // Activity log viewer
<Tab label="Settings"> // Platform settings (existing)
</Tabs>
```
---
## File Changes Summary
### Backend - New Files
| File Path | Purpose |
|-----------|---------|
| `backend/app/models/admin_audit_log.py` | Audit log data model |
| `backend/app/schemas/admin.py` | All admin-related Pydantic schemas |
| `backend/app/services/audit_service.py` | Audit logging utility functions |
| `backend/app/services/seeder_service.py` | Data seeding service |
| `backend/app/services/simulation_service.py` | Simulation actions service |
| `backend/app/services/simulation_manager.py` | Background task manager |
| `backend/app/services/wiper_service.py` | Data wipe service |
### Backend - Modified Files
| File Path | Changes |
|-----------|---------|
| `backend/app/models/__init__.py` | Export AdminAuditLog |
| `backend/app/models/admin_settings.py` | Add simulation_enabled, last_wipe_at fields |
| `backend/app/routers/admin.py` | Add all new endpoints |
| `backend/app/routers/websocket.py` | Add simulation status events |
| `backend/app/main.py` | Initialize simulation manager on startup |
### Frontend - New Files
| File Path | Purpose |
|-----------|---------|
| `frontend/src/components/admin/AdminDataTools.tsx` | Wipe/Seed UI |
| `frontend/src/components/admin/AdminSimulation.tsx` | Simulation controls |
| `frontend/src/components/admin/AdminUsers.tsx` | User management table |
| `frontend/src/components/admin/AdminAuditLog.tsx` | Audit log viewer |
| `frontend/src/components/admin/UserEditModal.tsx` | User edit dialog |
| `frontend/src/components/admin/BalanceAdjustModal.tsx` | Balance adjustment |
| `frontend/src/components/admin/ConfirmWipeModal.tsx` | Wipe confirmation |
| `frontend/src/types/admin.ts` | TypeScript types |
### Frontend - Modified Files
| File Path | Changes |
|-----------|---------|
| `frontend/src/pages/Admin.tsx` | Complete redesign with tabs |
| `frontend/src/api/admin.ts` | Add all new API methods |
---
## Risk Assessment
### Risk Register
| ID | Risk Description | Probability | Impact | Score | Response Strategy | Owner |
|----|------------------|-------------|--------|-------|-------------------|-------|
| R1 | Data wipe accidentally executed on production | Low | Critical | High | Implement multi-factor confirmation, rate limiting, and distinct visual warnings for destructive operations | Backend Lead |
| R2 | Simulation causes performance degradation | Medium | Medium | Medium | Implement resource limits, configurable delays, automatic pause on high load | Backend Lead |
| R3 | Audit log table grows too large | Medium | Low | Low | Implement log rotation/archival policy, add index on created_at | DBA/Backend |
| R4 | Balance adjustment creates accounting discrepancy | Low | High | Medium | Require reason, create Transaction records, implement double-entry validation | Backend Lead |
| R5 | Admin privileges escalation | Low | Critical | High | Audit all is_admin changes, require existing admin to grant, log IP addresses | Security Lead |
| R6 | WebSocket connection issues during simulation | Medium | Low | Low | Graceful degradation - simulation continues even if status updates fail | Frontend Lead |
| R7 | Race conditions during concurrent admin operations | Low | Medium | Medium | Use database transactions with proper isolation, implement optimistic locking where needed | Backend Lead |
### Mitigation Strategies
**R1 - Accidental Data Wipe:**
- Require exact confirmation phrase: "CONFIRM WIPE"
- Show count of records to be deleted before confirmation
- 5-minute cooldown between wipes
- Distinct red/warning styling on wipe button
- Audit log entry created BEFORE wipe executes
**R2 - Simulation Performance:**
- Configurable delay between iterations (default 2 seconds)
- Maximum 5 actions per iteration
- Automatic pause if > 100 pending database connections
- CPU/memory monitoring hooks
**R5 - Admin Escalation:**
- Cannot remove own admin privileges
- Cannot create admin if no existing admin (seed only)
- Email notification on admin role changes (future enhancement)
- All admin changes logged with IP, user agent
---
## Dependencies
### Task Dependencies Graph
```
Phase 1 (Foundation)
1.1.1 AdminAuditLog Model ─────────────────┐
1.1.2 AdminSettings Update ├──► 1.2.2 Audit Service ──► All subsequent features
1.1.3 Admin Schemas ───────────────────────┘
Phase 2 (Data Management) - Requires Phase 1
1.4.1 Seeder Service ──► 1.4.3 Seed Endpoints
1.3.2 Wipe Endpoints ──► 1.3.3 Safeguards
Phase 3 (Simulation) - Requires Phase 1
1.5.1 Simulation Service ──► 1.5.2 Simulation Manager ──► 1.5.3 Endpoints
Phase 4 (User Management) - Requires Phase 1
1.6.1 Schemas ──► 1.6.2-1.6.5 Endpoints
Phase 5 (Frontend) - Requires Backend Phases
Backend Phase 1 ──► 1.7.5 AdminAuditLog Component
Backend Phase 2 ──► 1.7.2 AdminDataTools Component
Backend Phase 3 ──► 1.7.3 AdminSimulation Component
Backend Phase 4 ──► 1.7.4 AdminUsers Component
All Components ──► 1.7.1 Admin.tsx Integration
```
### External Dependencies
| Dependency | Version | Purpose | Status |
|------------|---------|---------|--------|
| FastAPI | 0.100+ | Backend framework | Existing |
| SQLAlchemy | 2.0+ | ORM with async support | Existing |
| React | 18+ | Frontend framework | Existing |
| TanStack Query | 5+ | Server state management | Existing |
| Tailwind CSS | 3+ | Styling | Existing |
---
## Acceptance Criteria
### Feature 1: Data Wiper
| Criteria | Description | Validation Method |
|----------|-------------|-------------------|
| AC1.1 | Admin can select wipe type (all, bets, events, transactions, users_except_admin) | UI/API test |
| AC1.2 | Wipe requires exact confirmation phrase "CONFIRM WIPE" | API test with incorrect phrase should fail |
| AC1.3 | Wipe shows count of affected records before confirmation | UI displays counts |
| AC1.4 | 5-minute cooldown enforced between wipes | API returns 429 within cooldown |
| AC1.5 | Wipe is logged to audit log before execution | Audit log entry exists |
| AC1.6 | User wallets reset to clean state after relevant wipes | Balance = 0, Escrow = 0 |
### Feature 2: Data Seeder
| Criteria | Description | Validation Method |
|----------|-------------|-------------------|
| AC2.1 | Admin can specify number of users, events, bets to create | API accepts parameters |
| AC2.2 | Seeded users have valid credentials (password123) | Can login as seeded user |
| AC2.3 | Seeded bets follow business rules (valid stakes, statuses) | Data validation |
| AC2.4 | Seed operation is idempotent (no duplicates on repeat) | Run twice, check counts |
| AC2.5 | Seed action logged to audit log | Audit entry with counts |
### Feature 3: Activity Simulation
| Criteria | Description | Validation Method |
|----------|-------------|-------------------|
| AC3.1 | Admin can start simulation with configurable delay | API accepts config |
| AC3.2 | Admin can stop running simulation | Status changes to stopped |
| AC3.3 | Simulation status visible in real-time | WebSocket or polling updates |
| AC3.4 | Simulation creates realistic activity (users, bets, comments) | Check new records created |
| AC3.5 | Simulation survives without active WebSocket connections | Background task continues |
| AC3.6 | Start/stop actions logged to audit log | Audit entries exist |
### Feature 4: User Management
| Criteria | Description | Validation Method |
|----------|-------------|-------------------|
| AC4.1 | Admin can view paginated list of all users | API returns paginated results |
| AC4.2 | Admin can search users by email/username | Search returns filtered results |
| AC4.3 | Admin can edit user display name | Update persists |
| AC4.4 | Admin can disable/enable user accounts | Status changes, user cannot login when disabled |
| AC4.5 | Admin can adjust user balance (positive/negative) | Balance updated, Transaction created |
| AC4.6 | Balance adjustment requires reason | API rejects empty reason |
| AC4.7 | Cannot reduce balance below current escrow | API returns validation error |
| AC4.8 | All user modifications logged | Audit entries with details |
### Feature 5: Audit Log
| Criteria | Description | Validation Method |
|----------|-------------|-------------------|
| AC5.1 | All admin actions create audit log entries | Check log after each action |
| AC5.2 | Audit log displays with pagination | API returns paginated results |
| AC5.3 | Audit log filterable by action type, date range, admin | Filters work correctly |
| AC5.4 | Audit entries include admin username, timestamp, details | All fields populated |
| AC5.5 | Audit log is append-only (entries cannot be deleted via API) | No DELETE endpoint |
---
## API Endpoint Summary
### New Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/admin/audit-logs` | List audit logs with filters |
| POST | `/api/v1/admin/data/wipe` | Wipe database data |
| GET | `/api/v1/admin/data/wipe/preview` | Preview wipe (record counts) |
| POST | `/api/v1/admin/data/seed` | Seed test data |
| GET | `/api/v1/admin/simulation/status` | Get simulation status |
| POST | `/api/v1/admin/simulation/start` | Start simulation |
| POST | `/api/v1/admin/simulation/stop` | Stop simulation |
| GET | `/api/v1/admin/users` | List users |
| GET | `/api/v1/admin/users/{user_id}` | Get user details |
| PATCH | `/api/v1/admin/users/{user_id}` | Update user |
| PATCH | `/api/v1/admin/users/{user_id}/status` | Toggle user status |
| POST | `/api/v1/admin/users/{user_id}/balance` | Adjust balance |
---
## Database Schema Changes
### New Table: admin_audit_logs
```sql
CREATE TABLE admin_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_user_id INTEGER NOT NULL REFERENCES users(id),
action VARCHAR(100) NOT NULL,
action_category VARCHAR(50) NOT NULL,
target_type VARCHAR(50),
target_id INTEGER,
details JSON,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_admin_user (admin_user_id),
INDEX idx_audit_action (action),
INDEX idx_audit_created (created_at),
INDEX idx_audit_category (action_category)
);
```
### Modified Table: admin_settings
```sql
ALTER TABLE admin_settings ADD COLUMN simulation_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE admin_settings ADD COLUMN simulation_delay_seconds FLOAT DEFAULT 2.0;
ALTER TABLE admin_settings ADD COLUMN simulation_actions_per_iteration INTEGER DEFAULT 3;
ALTER TABLE admin_settings ADD COLUMN last_wipe_at TIMESTAMP;
```
### New Transaction Type
```python
class TransactionType(enum.Enum):
# ... existing types ...
ADMIN_ADJUSTMENT = "admin_adjustment" # New type for balance adjustments
```
---
## Quality Assurance Plan
### Test Coverage Requirements
| Component | Unit Tests | Integration Tests | E2E Tests |
|-----------|------------|-------------------|-----------|
| Audit Service | 90%+ | Required | N/A |
| Wiper Service | 90%+ | Required | Required |
| Seeder Service | 80%+ | Required | Optional |
| Simulation Manager | 80%+ | Required | Optional |
| User Management Endpoints | N/A | Required | Required |
| Frontend Components | N/A | N/A | Required |
### Test Scenarios
**Data Wiper:**
1. Successful wipe with correct confirmation
2. Rejected wipe with incorrect confirmation
3. Cooldown enforcement
4. Partial wipe (bets only)
5. Full wipe preserving admin
**Simulation:**
1. Start simulation
2. Stop running simulation
3. Status updates while running
4. Restart after stop
5. Behavior with no events/users
**User Management:**
1. List with pagination
2. Search by email
3. Update user details
4. Disable active user
5. Positive balance adjustment
6. Negative balance adjustment
7. Balance adjustment below escrow (should fail)
---
## Appendix A: Audit Log Action Codes
| Action Code | Category | Description |
|-------------|----------|-------------|
| `DATA_WIPE` | data_management | Database wipe executed |
| `DATA_SEED` | data_management | Test data seeded |
| `SIMULATION_START` | simulation | Simulation started |
| `SIMULATION_STOP` | simulation | Simulation stopped |
| `USER_UPDATE` | user_management | User details modified |
| `USER_DISABLE` | user_management | User account disabled |
| `USER_ENABLE` | user_management | User account enabled |
| `USER_BALANCE_ADJUST` | user_management | Balance adjusted |
| `USER_ADMIN_GRANT` | user_management | Admin role granted |
| `USER_ADMIN_REVOKE` | user_management | Admin role revoked |
| `EVENT_CREATE` | event_management | Sport event created |
| `EVENT_UPDATE` | event_management | Sport event updated |
| `EVENT_DELETE` | event_management | Sport event deleted |
| `SETTINGS_UPDATE` | settings | Platform settings changed |
---
## Appendix B: UI Wireframes
### Admin Page - Tab Layout
```
+------------------------------------------------------------------+
| Admin Panel [Settings] |
+------------------------------------------------------------------+
| [Events] [Users] [Data Tools] [Simulation] [Audit Log] |
+------------------------------------------------------------------+
| |
| [Tab Content Area] |
| |
+------------------------------------------------------------------+
```
### Data Tools Tab
```
+------------------------------------------------------------------+
| Data Management |
+------------------------------------------------------------------+
| |
| +-- Database Reset ------------------------------------------+ |
| | | |
| | [!] Warning: This will permanently delete data | |
| | | |
| | Wipe Type: [Dropdown: All/Bets/Events/Transactions/Users]| |
| | | |
| | Records to delete: | |
| | - Bets: 145 | |
| | - Events: 23 | |
| | - Transactions: 892 | |
| | | |
| | [Wipe Data - Requires Confirmation] | |
| +------------------------------------------------------------+ |
| |
| +-- Seed Test Data -----------------------------------------+ |
| | | |
| | Users to create: [__3__] | |
| | Events to create: [__5__] | |
| | Bets to create: [__10__] | |
| | [x] Include matched bets | |
| | | |
| | [Seed Data] | |
| +------------------------------------------------------------+ |
+------------------------------------------------------------------+
```
### User Management Tab
```
+------------------------------------------------------------------+
| User Management |
+------------------------------------------------------------------+
| |
| Search: [________________________] [Search] |
| |
| +-------+----------+-------------+--------+--------+---------+ |
| | ID | Username | Email | Status | Balance| Actions | |
| +-------+----------+-------------+--------+--------+---------+ |
| | 1 | alice | alice@... | Active | $1,000 | [Edit] | |
| | 2 | bob | bob@... | Active | $850 | [Edit] | |
| | 3 | charlie | charlie@... | Disabled| $500 | [Edit] | |
| +-------+----------+-------------+--------+--------+---------+ |
| |
| [< Prev] Page 1 of 5 [Next >] |
+------------------------------------------------------------------+
```
---
## Revision History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | 2026-01-11 | Claude | Initial project plan |

View File

@ -0,0 +1,195 @@
# Admin Panel Playwright Test Report
**Date:** 2026-01-11
**Test File:** `/Users/liamdeez/Work/ai/h2h-prototype/frontend/tests/admin-panel.spec.ts`
**Total Tests:** 44
**Passing:** 2
**Failing:** 42
## Executive Summary
The Admin Panel Playwright E2E tests were created to verify the functionality of the comprehensive Admin Panel implementation. The tests cover all six tabs: Dashboard, Users, Events, Data Tools, Simulation, and Audit Log.
However, the majority of tests are currently failing due to **authentication challenges** in the test environment. The Admin Panel requires an authenticated admin user (`is_admin: true`), and the current test setup is unable to properly mock authentication due to the Zustand state management architecture.
## Test Results Breakdown
### Passing Tests (2)
| Test | Status | Duration |
|------|--------|----------|
| Admin Panel - Access Control > should redirect to login when not authenticated | PASS | 809ms |
| Admin Panel - Access Control > should show login form with correct fields | PASS | 282ms |
### Failing Tests (42)
All failing tests are blocked by authentication. They timeout waiting for Admin Panel elements while the page redirects to the login screen.
**Root Cause:**
- The Admin Panel uses `AdminRoute` which checks `useAuthStore().user?.is_admin`
- The Zustand store calls `authApi.getCurrentUser()` via `loadUser()` on app initialization
- Network mocking with `page.route()` is not intercepting the auth API calls early enough in the page lifecycle
- The mock tokens in localStorage are validated against the real backend, which returns 401 Unauthorized
## Test Coverage Plan
The test file covers the following functionality (when auth is resolved):
### 1. Access Control (2 tests)
- [x] Redirect to login when not authenticated
- [x] Display login form with correct fields
- [ ] Show error for invalid credentials (flaky - depends on backend response timing)
### 2. Dashboard Tab (4 tests)
- [ ] Display Admin Panel header
- [ ] Display all navigation tabs
- [ ] Display dashboard stats (Total Users, Events, Bets, Volume)
- [ ] Display Platform Settings when loaded
### 3. Users Tab (6 tests)
- [ ] Display search input
- [ ] Display status filter dropdown
- [ ] Display users table headers
- [ ] Display user data in table
- [ ] Allow typing in search input
- [ ] Change status filter
### 4. Events Tab (6 tests)
- [ ] Display Create Event button
- [ ] Display All Events section
- [ ] Open create event form
- [ ] Display form fields when creating event
- [ ] Close form when clicking Cancel
- [ ] Display event in list
### 5. Data Tools Tab (10 tests)
- [ ] Display Data Wiper section
- [ ] Display Danger Zone warning
- [ ] Display wipe preview counts
- [ ] Display Data Seeder section
- [ ] Display seed configuration inputs
- [ ] Have Wipe Database button
- [ ] Have Seed Database button
- [ ] Open wipe confirmation modal
- [ ] Require confirmation phrase in wipe modal
- [ ] Close wipe modal when clicking Cancel
### 6. Simulation Tab (6 tests)
- [ ] Display Activity Simulation header
- [ ] Display simulation status badge (Stopped)
- [ ] Display Start Simulation button when stopped
- [ ] Display Configure button when stopped
- [ ] Show configuration panel when clicking Configure
- [ ] Display configuration options
### 7. Audit Log Tab (5 tests)
- [ ] Display Audit Log header
- [ ] Display action filter dropdown
- [ ] Display log entries
- [ ] Display log action badges
- [ ] Change filter selection
### 8. Tab Navigation (2 tests)
- [ ] Switch between all tabs
- [ ] Highlight active tab
### 9. Responsive Behavior (2 tests)
- [ ] Render on tablet viewport
- [ ] Render on mobile viewport
## Recommendations
### Immediate Actions Required
1. **Create Admin Test User**
Add an admin user to the seed data:
```python
# In backend/seed_data.py
admin_user = User(
email="admin@example.com",
username="admin",
password_hash=get_password_hash("admin123"),
display_name="Admin User",
is_admin=True
)
```
2. **Use Real Authentication in Tests**
Update tests to perform actual login with the admin user instead of mocking:
```typescript
async function loginAsAdmin(page: Page): Promise<void> {
await page.goto(`${BASE_URL}/login`);
await page.locator('#email').fill('admin@example.com');
await page.locator('#password').fill('admin123');
await page.locator('button[type="submit"]').click();
await page.waitForURL(/^(?!.*login).*/, { timeout: 10000 });
}
```
3. **Alternative: Use Playwright Auth State**
Implement Playwright's `storageState` feature to save and reuse authentication:
```typescript
// In playwright.config.ts or global-setup.ts
await page.context().storageState({ path: 'auth.json' });
// In tests
test.use({ storageState: 'auth.json' });
```
### Long-term Improvements
1. **Backend Test Mode**
Add a test mode to the backend that accepts a special test token without validation.
2. **Dependency Injection for Auth Store**
Modify the auth store to accept an initial state for testing purposes.
3. **Component Testing**
Consider adding Playwright component tests that can test the Admin Panel components in isolation without full authentication flow.
## Files Modified/Created
- **Created:** `/Users/liamdeez/Work/ai/h2h-prototype/frontend/tests/admin-panel.spec.ts`
- **Created:** `/Users/liamdeez/Work/ai/h2h-prototype/frontend/ADMIN_PANEL_TEST_REPORT.md`
## How to Run Tests
```bash
cd /Users/liamdeez/Work/ai/h2h-prototype/frontend
# Run all admin panel tests
npx playwright test tests/admin-panel.spec.ts
# Run with UI mode for debugging
npx playwright test tests/admin-panel.spec.ts --ui
# Run specific test suite
npx playwright test tests/admin-panel.spec.ts -g "Access Control"
# Show test report
npx playwright show-report
```
## Technical Details
### Authentication Flow
1. User visits `/admin`
2. `AdminRoute` component checks `useAuthStore().isAuthenticated` and `user?.is_admin`
3. If not authenticated, redirects to `/login`
4. On login page load, `App.tsx` calls `loadUser()`
5. `loadUser()` reads `access_token` from localStorage
6. If token exists, calls `GET /api/v1/auth/me` to validate
7. If validation fails, clears localStorage and sets `isAuthenticated: false`
### Why Mocking Failed
- `page.addInitScript()` sets localStorage before page load - WORKS
- `page.route()` should intercept API calls - PARTIALLY WORKS
- The timing of route registration vs. the React app's initial API call creates a race condition
- The Zustand store initializes synchronously from localStorage, then validates asynchronously
- The redirect happens before the route mock can intercept the auth/me call
## Conclusion
The test framework is in place and tests are well-structured. The primary blocker is the authentication mechanism. Once an admin user is created in the database and the tests are updated to use real authentication (or a proper auth state fixture), all 44 tests should pass.
The Admin Panel UI components are correctly implemented based on the test design - the tests accurately target the expected elements (headers, buttons, form fields, etc.) that are defined in the Admin.tsx and its child components.

View File

@ -1,5 +1,24 @@
import { apiClient } from './client'
import type { SportEvent } from '@/types/sport-event'
import type {
AdminDashboardStats,
AuditLogListResponse,
WipePreview,
WipeRequest,
WipeResponse,
SeedRequest,
SeedResponse,
SimulationStatus,
SimulationConfig,
SimulationStartResponse,
SimulationStopResponse,
AdminUserListResponse,
AdminUserDetail,
UserUpdateRequest,
UserStatusRequest,
BalanceAdjustRequest,
BalanceAdjustResponse,
} from '@/types/admin'
export interface AdminSettings {
id: number
@ -28,6 +47,13 @@ export interface CreateEventData {
}
export const adminApi = {
// Dashboard
getDashboard: async (): Promise<AdminDashboardStats> => {
const response = await apiClient.get<AdminDashboardStats>('/api/v1/admin/dashboard')
return response.data
},
// Settings
getSettings: async (): Promise<AdminSettings> => {
const response = await apiClient.get<AdminSettings>('/api/v1/admin/settings')
return response.data
@ -38,6 +64,7 @@ export const adminApi = {
return response.data
},
// Events
createEvent: async (eventData: CreateEventData): Promise<SportEvent> => {
const response = await apiClient.post<SportEvent>('/api/v1/admin/events', eventData)
return response.data
@ -57,4 +84,111 @@ export const adminApi = {
const response = await apiClient.delete<{ message: string }>(`/api/v1/admin/events/${eventId}`)
return response.data
},
// Audit Logs
getAuditLogs: async (params?: {
page?: number
page_size?: number
action?: string
admin_id?: number
target_type?: string
}): Promise<AuditLogListResponse> => {
const queryParams = new URLSearchParams()
if (params?.page) queryParams.append('page', params.page.toString())
if (params?.page_size) queryParams.append('page_size', params.page_size.toString())
if (params?.action) queryParams.append('action', params.action)
if (params?.admin_id) queryParams.append('admin_id', params.admin_id.toString())
if (params?.target_type) queryParams.append('target_type', params.target_type)
const url = `/api/v1/admin/audit-logs${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<AuditLogListResponse>(url)
return response.data
},
// Data Wiper
getWipePreview: async (): Promise<WipePreview> => {
const response = await apiClient.get<WipePreview>('/api/v1/admin/data/wipe/preview')
return response.data
},
executeWipe: async (request: WipeRequest): Promise<WipeResponse> => {
const response = await apiClient.post<WipeResponse>('/api/v1/admin/data/wipe', request)
return response.data
},
// Data Seeder
seedDatabase: async (request: SeedRequest): Promise<SeedResponse> => {
const response = await apiClient.post<SeedResponse>('/api/v1/admin/data/seed', request)
return response.data
},
// Simulation
getSimulationStatus: async (): Promise<SimulationStatus> => {
const response = await apiClient.get<SimulationStatus>('/api/v1/admin/simulation/status')
return response.data
},
startSimulation: async (config?: SimulationConfig): Promise<SimulationStartResponse> => {
const response = await apiClient.post<SimulationStartResponse>('/api/v1/admin/simulation/start', {
config,
})
return response.data
},
stopSimulation: async (): Promise<SimulationStopResponse> => {
const response = await apiClient.post<SimulationStopResponse>('/api/v1/admin/simulation/stop')
return response.data
},
// User Management
getUsers: async (params?: {
page?: number
page_size?: number
search?: string
status_filter?: string
is_admin?: boolean
}): Promise<AdminUserListResponse> => {
const queryParams = new URLSearchParams()
if (params?.page) queryParams.append('page', params.page.toString())
if (params?.page_size) queryParams.append('page_size', params.page_size.toString())
if (params?.search) queryParams.append('search', params.search)
if (params?.status_filter) queryParams.append('status_filter', params.status_filter)
if (params?.is_admin !== undefined) queryParams.append('is_admin', params.is_admin.toString())
const url = `/api/v1/admin/users${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<AdminUserListResponse>(url)
return response.data
},
getUser: async (userId: number): Promise<AdminUserDetail> => {
const response = await apiClient.get<AdminUserDetail>(`/api/v1/admin/users/${userId}`)
return response.data
},
updateUser: async (
userId: number,
data: UserUpdateRequest
): Promise<{ message: string; changes: Record<string, unknown> }> => {
const response = await apiClient.patch<{ message: string; changes: Record<string, unknown> }>(
`/api/v1/admin/users/${userId}`,
data
)
return response.data
},
changeUserStatus: async (userId: number, data: UserStatusRequest): Promise<{ message: string }> => {
const response = await apiClient.patch<{ message: string }>(
`/api/v1/admin/users/${userId}/status`,
data
)
return response.data
},
adjustUserBalance: async (userId: number, data: BalanceAdjustRequest): Promise<BalanceAdjustResponse> => {
const response = await apiClient.post<BalanceAdjustResponse>(
`/api/v1/admin/users/${userId}/balance`,
data
)
return response.data
},
}

View File

@ -0,0 +1,173 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Loading } from '@/components/common/Loading'
import { Button } from '@/components/common/Button'
import { ClipboardList, ChevronLeft, ChevronRight, Filter, Clock } from 'lucide-react'
import { format } from 'date-fns'
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
DATA_WIPE: { label: 'Data Wipe', color: 'bg-red-100 text-red-800' },
DATA_SEED: { label: 'Data Seed', color: 'bg-blue-100 text-blue-800' },
SIMULATION_START: { label: 'Simulation Start', color: 'bg-green-100 text-green-800' },
SIMULATION_STOP: { label: 'Simulation Stop', color: 'bg-yellow-100 text-yellow-800' },
USER_STATUS_CHANGE: { label: 'User Status', color: 'bg-purple-100 text-purple-800' },
USER_BALANCE_ADJUST: { label: 'Balance Adjust', color: 'bg-indigo-100 text-indigo-800' },
USER_ADMIN_GRANT: { label: 'Admin Grant', color: 'bg-blue-100 text-blue-800' },
USER_ADMIN_REVOKE: { label: 'Admin Revoke', color: 'bg-orange-100 text-orange-800' },
USER_UPDATE: { label: 'User Update', color: 'bg-gray-100 text-gray-800' },
SETTINGS_UPDATE: { label: 'Settings Update', color: 'bg-cyan-100 text-cyan-800' },
EVENT_CREATE: { label: 'Event Create', color: 'bg-green-100 text-green-800' },
EVENT_UPDATE: { label: 'Event Update', color: 'bg-blue-100 text-blue-800' },
EVENT_DELETE: { label: 'Event Delete', color: 'bg-red-100 text-red-800' },
}
export const AdminAuditLog = () => {
const [page, setPage] = useState(1)
const [actionFilter, setActionFilter] = useState('')
const [expandedLog, setExpandedLog] = useState<number | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['admin-audit-logs', page, actionFilter],
queryFn: () =>
adminApi.getAuditLogs({
page,
page_size: 25,
action: actionFilter || undefined,
}),
})
if (isLoading) {
return <Loading />
}
const totalPages = data ? Math.ceil(data.total / data.page_size) : 1
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ClipboardList className="text-gray-400" size={24} />
<div>
<h2 className="text-xl font-semibold text-gray-900">Audit Log</h2>
<p className="text-sm text-gray-500">Track all admin actions on the platform</p>
</div>
</div>
<div className="flex items-center gap-2">
<Filter size={18} className="text-gray-400" />
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value)
setPage(1)
}}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Actions</option>
{Object.entries(ACTION_LABELS).map(([value, { label }]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Logs List */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{data?.logs.length === 0 ? (
<div className="p-8 text-center text-gray-500">No audit logs found</div>
) : (
<div className="divide-y">
{data?.logs.map((log) => {
const actionInfo = ACTION_LABELS[log.action] || {
label: log.action,
color: 'bg-gray-100 text-gray-800',
}
const isExpanded = expandedLog === log.id
let details: Record<string, any> | null = null
try {
details = log.details ? JSON.parse(log.details) : null
} catch {
details = null
}
return (
<div
key={log.id}
className={`p-4 hover:bg-gray-50 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : ''
}`}
onClick={() => setExpandedLog(isExpanded ? null : log.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div
className={`px-2 py-1 rounded text-xs font-medium ${actionInfo.color}`}
>
{actionInfo.label}
</div>
<div>
<p className="text-gray-900">{log.description}</p>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500">
<span>By: {log.admin_username}</span>
{log.target_type && (
<span>
Target: {log.target_type} #{log.target_id}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Clock size={14} />
<span>{format(new Date(log.created_at), 'MMM d, yyyy h:mm a')}</span>
</div>
</div>
{/* Expanded Details */}
{isExpanded && details && (
<div className="mt-4 p-3 bg-gray-100 rounded-lg">
<p className="text-sm font-medium text-gray-600 mb-2">Details:</p>
<pre className="text-xs text-gray-700 overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
{log.ip_address && (
<p className="mt-2 text-xs text-gray-500">IP: {log.ip_address}</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
<p className="text-sm text-gray-600">
Page {page} of {totalPages} ({data?.total || 0} total logs)
</p>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPage(page - 1)} disabled={page === 1}>
<ChevronLeft size={18} />
</Button>
<Button
variant="secondary"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
>
<ChevronRight size={18} />
</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,290 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { AlertTriangle, Database, Trash2, RefreshCw } from 'lucide-react'
import toast from 'react-hot-toast'
import type { SeedRequest } from '@/types/admin'
export const AdminDataTools = () => {
const queryClient = useQueryClient()
const [showWipeModal, setShowWipeModal] = useState(false)
const [confirmPhrase, setConfirmPhrase] = useState('')
const [preserveAdmins, setPreserveAdmins] = useState(true)
const [preserveEvents, setPreserveEvents] = useState(false)
const [seedConfig, setSeedConfig] = useState<SeedRequest>({
num_users: 10,
num_events: 5,
num_bets_per_event: 3,
starting_balance: 1000,
create_admin: true,
})
const { data: wipePreview, isLoading: isLoadingPreview, refetch: refetchPreview } = useQuery({
queryKey: ['wipe-preview'],
queryFn: adminApi.getWipePreview,
})
const wipeMutation = useMutation({
mutationFn: adminApi.executeWipe,
onSuccess: (data) => {
toast.success(data.message)
setShowWipeModal(false)
setConfirmPhrase('')
queryClient.invalidateQueries()
refetchPreview()
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Wipe failed')
},
})
const seedMutation = useMutation({
mutationFn: adminApi.seedDatabase,
onSuccess: (data) => {
console.log('Seed success:', data)
toast.success(data.message)
if (data.test_admin) {
toast.success(`Test admin created: ${data.test_admin.username} / ${data.test_admin.password}`, {
duration: 10000,
})
}
// Show what was created
const counts = data.created_counts
toast.success(`Created: ${counts.users} users, ${counts.events} events, ${counts.bets} bets`, {
duration: 5000,
})
queryClient.invalidateQueries()
refetchPreview()
},
onError: (error: any) => {
console.error('Seed error:', error)
const message = error.response?.data?.detail || error.message || 'Seed failed'
toast.error(message)
},
})
const handleWipe = () => {
wipeMutation.mutate({
confirmation_phrase: confirmPhrase,
preserve_admin_users: preserveAdmins,
preserve_events: preserveEvents,
})
}
const handleSeed = () => {
seedMutation.mutate(seedConfig)
}
if (isLoadingPreview) {
return <Loading />
}
return (
<div className="space-y-6">
{/* Wipe Preview */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Trash2 className="text-red-500" size={24} />
<h2 className="text-xl font-semibold text-gray-900">Data Wiper</h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
<div>
<p className="text-red-800 font-medium">Danger Zone</p>
<p className="text-red-600 text-sm">
This action will permanently delete data from the database. Admin users and settings are preserved by default.
</p>
</div>
</div>
</div>
{wipePreview && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Users</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.users_count}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Spread Bets</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.spread_bets_count}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Events</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.events_count}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Transactions</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.transactions_count}</p>
</div>
</div>
)}
{wipePreview && !wipePreview.can_wipe && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p className="text-yellow-800">
Cooldown active. Please wait {wipePreview.cooldown_remaining_seconds} seconds before wiping again.
</p>
</div>
)}
<Button
variant="secondary"
onClick={() => setShowWipeModal(true)}
disabled={!wipePreview?.can_wipe}
className="bg-red-600 hover:bg-red-700 text-white border-red-600"
>
<Trash2 size={18} className="mr-2" />
Wipe Database
</Button>
</div>
{/* Wipe Confirmation Modal */}
{showWipeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-bold text-red-600 mb-4">Confirm Database Wipe</h3>
<div className="space-y-4 mb-6">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={preserveAdmins}
onChange={(e) => setPreserveAdmins(e.target.checked)}
className="rounded"
/>
<span className="text-gray-700">Preserve admin users</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={preserveEvents}
onChange={(e) => setPreserveEvents(e.target.checked)}
className="rounded"
/>
<span className="text-gray-700">Preserve events (delete bets only)</span>
</label>
<div>
<label className="block text-gray-700 font-medium mb-2">
Type <span className="font-mono bg-gray-100 px-2 py-1 rounded">CONFIRM WIPE</span> to proceed:
</label>
<input
type="text"
value={confirmPhrase}
onChange={(e) => setConfirmPhrase(e.target.value)}
placeholder="CONFIRM WIPE"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => {
setShowWipeModal(false)
setConfirmPhrase('')
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleWipe}
disabled={confirmPhrase !== 'CONFIRM WIPE' || wipeMutation.isPending}
className="flex-1 bg-red-600 hover:bg-red-700"
>
{wipeMutation.isPending ? 'Wiping...' : 'Wipe Data'}
</Button>
</div>
</div>
</div>
)}
{/* Seeder */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Database className="text-blue-500" size={24} />
<h2 className="text-xl font-semibold text-gray-900">Data Seeder</h2>
</div>
<p className="text-gray-600 mb-4">
Populate the database with test data for development and testing.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Number of Users</label>
<input
type="number"
value={seedConfig.num_users}
onChange={(e) => setSeedConfig({ ...seedConfig, num_users: Math.max(1, parseInt(e.target.value) || 1) })}
min={1}
max={100}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Number of Events</label>
<input
type="number"
value={seedConfig.num_events}
onChange={(e) => setSeedConfig({ ...seedConfig, num_events: Math.max(0, parseInt(e.target.value) || 0) })}
min={0}
max={50}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Bets per Event</label>
<input
type="number"
value={seedConfig.num_bets_per_event}
onChange={(e) => setSeedConfig({ ...seedConfig, num_bets_per_event: Math.max(0, parseInt(e.target.value) || 0) })}
min={0}
max={20}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Starting Balance</label>
<input
type="number"
value={seedConfig.starting_balance}
onChange={(e) => setSeedConfig({ ...seedConfig, starting_balance: parseInt(e.target.value) || 100 })}
min={100}
max={10000}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={seedConfig.create_admin}
onChange={(e) => setSeedConfig({ ...seedConfig, create_admin: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Create test admin</span>
</label>
</div>
</div>
<Button onClick={handleSeed} disabled={seedMutation.isPending}>
<RefreshCw size={18} className={`mr-2 ${seedMutation.isPending ? 'animate-spin' : ''}`} />
{seedMutation.isPending ? 'Seeding...' : 'Seed Database'}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,226 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { Play, Square, Settings, Activity } from 'lucide-react'
import toast from 'react-hot-toast'
import type { SimulationConfig } from '@/types/admin'
export const AdminSimulation = () => {
const queryClient = useQueryClient()
const [showConfig, setShowConfig] = useState(false)
const [config, setConfig] = useState<SimulationConfig>({
delay_seconds: 2.0,
actions_per_iteration: 3,
create_users: true,
create_bets: true,
take_bets: true,
add_comments: true,
cancel_bets: true,
})
const { data: status, isLoading, refetch } = useQuery({
queryKey: ['simulation-status'],
queryFn: adminApi.getSimulationStatus,
refetchInterval: (query) => (query.state.data?.is_running ? 2000 : false),
})
// Update config when status loads
useEffect(() => {
if (status?.config) {
setConfig(status.config)
}
}, [status?.config])
const startMutation = useMutation({
mutationFn: () => adminApi.startSimulation(config),
onSuccess: (data) => {
toast.success(data.message)
queryClient.invalidateQueries({ queryKey: ['simulation-status'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to start simulation')
},
})
const stopMutation = useMutation({
mutationFn: adminApi.stopSimulation,
onSuccess: (data) => {
toast.success(`${data.message} (${data.total_iterations} iterations, ${data.ran_for_seconds.toFixed(1)}s)`)
queryClient.invalidateQueries({ queryKey: ['simulation-status'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to stop simulation')
},
})
if (isLoading) {
return <Loading />
}
return (
<div className="space-y-6">
{/* Status Card */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Activity className={status?.is_running ? 'text-green-500 animate-pulse' : 'text-gray-400'} size={24} />
<h2 className="text-xl font-semibold text-gray-900">Activity Simulation</h2>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
status?.is_running ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}
>
{status?.is_running ? 'Running' : 'Stopped'}
</span>
</div>
<p className="text-gray-600 mb-6">
Simulate random user activity including creating users, placing bets, matching bets, and adding comments.
</p>
{status?.is_running && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-gray-600 text-sm">Started By</p>
<p className="font-semibold text-gray-900">{status.started_by}</p>
</div>
<div>
<p className="text-gray-600 text-sm">Iterations</p>
<p className="font-semibold text-gray-900">{status.iterations_completed}</p>
</div>
<div>
<p className="text-gray-600 text-sm">Started At</p>
<p className="font-semibold text-gray-900">
{status.started_at ? new Date(status.started_at).toLocaleTimeString() : '-'}
</p>
</div>
<div>
<p className="text-gray-600 text-sm">Last Activity</p>
<p className="font-semibold text-gray-900 truncate">{status.last_activity || '-'}</p>
</div>
</div>
</div>
)}
<div className="flex gap-3">
{status?.is_running ? (
<Button
onClick={() => stopMutation.mutate()}
disabled={stopMutation.isPending}
className="bg-red-600 hover:bg-red-700"
>
<Square size={18} className="mr-2" />
{stopMutation.isPending ? 'Stopping...' : 'Stop Simulation'}
</Button>
) : (
<>
<Button onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
<Play size={18} className="mr-2" />
{startMutation.isPending ? 'Starting...' : 'Start Simulation'}
</Button>
<Button variant="secondary" onClick={() => setShowConfig(!showConfig)}>
<Settings size={18} className="mr-2" />
Configure
</Button>
</>
)}
</div>
</div>
{/* Configuration */}
{showConfig && !status?.is_running && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Simulation Configuration</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Delay (seconds)</label>
<input
type="number"
value={config.delay_seconds}
onChange={(e) => setConfig({ ...config, delay_seconds: parseFloat(e.target.value) || 0.5 })}
min={0.5}
max={30}
step={0.5}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Actions per Iteration</label>
<input
type="number"
value={config.actions_per_iteration}
onChange={(e) => setConfig({ ...config, actions_per_iteration: parseInt(e.target.value) || 1 })}
min={1}
max={10}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="space-y-3">
<p className="text-gray-700 font-medium">Enabled Actions:</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.create_users}
onChange={(e) => setConfig({ ...config, create_users: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Create Users</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.create_bets}
onChange={(e) => setConfig({ ...config, create_bets: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Create Bets</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.take_bets}
onChange={(e) => setConfig({ ...config, take_bets: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Take/Match Bets</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.add_comments}
onChange={(e) => setConfig({ ...config, add_comments: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Add Comments</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.cancel_bets}
onChange={(e) => setConfig({ ...config, cancel_bets: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Cancel Bets</span>
</label>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,333 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { Search, User, Shield, Ban, DollarSign, Edit, ChevronLeft, ChevronRight } from 'lucide-react'
import toast from 'react-hot-toast'
import type { AdminUser, BalanceAdjustRequest } from '@/types/admin'
export const AdminUsers = () => {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [showBalanceModal, setShowBalanceModal] = useState(false)
const [balanceAmount, setBalanceAmount] = useState('')
const [balanceReason, setBalanceReason] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['admin-users', page, search, statusFilter],
queryFn: () =>
adminApi.getUsers({
page,
page_size: 20,
search: search || undefined,
status_filter: statusFilter || undefined,
}),
})
const statusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: number; status: 'active' | 'suspended' }) =>
adminApi.changeUserStatus(userId, { status }),
onSuccess: () => {
toast.success('User status updated')
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
setSelectedUser(null)
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to update status')
},
})
const adminMutation = useMutation({
mutationFn: ({ userId, isAdmin }: { userId: number; isAdmin: boolean }) =>
adminApi.updateUser(userId, { is_admin: isAdmin }),
onSuccess: () => {
toast.success('Admin privileges updated')
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to update privileges')
},
})
const balanceMutation = useMutation({
mutationFn: ({ userId, data }: { userId: number; data: BalanceAdjustRequest }) =>
adminApi.adjustUserBalance(userId, data),
onSuccess: (data) => {
toast.success(`Balance adjusted: $${data.previous_balance}$${data.new_balance}`)
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
setShowBalanceModal(false)
setSelectedUser(null)
setBalanceAmount('')
setBalanceReason('')
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to adjust balance')
},
})
const handleSearch = () => {
setSearch(searchInput)
setPage(1)
}
const handleBalanceSubmit = () => {
if (!selectedUser || !balanceAmount || !balanceReason) return
balanceMutation.mutate({
userId: selectedUser.id,
data: {
amount: parseFloat(balanceAmount),
reason: balanceReason,
},
})
}
if (isLoading) {
return <Loading />
}
const totalPages = data ? Math.ceil(data.total / data.page_size) : 1
return (
<div className="space-y-6">
{/* Search and Filters */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 flex gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Search by username, email, or name..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<Button onClick={handleSearch}>
<Search size={18} />
</Button>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
</div>
</div>
{/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">User</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Status</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Balance</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Stats</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Joined</th>
<th className="text-right px-4 py-3 text-sm font-semibold text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{data?.users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<User size={20} className="text-gray-500" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{user.username}</span>
{user.is_admin && (
<Shield size={14} className="text-blue-500" title="Admin" />
)}
</div>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
user.status === 'active'
? 'bg-green-100 text-green-800'
: user.status === 'suspended'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{user.status}
</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-gray-900">${Number(user.balance).toFixed(2)}</p>
{Number(user.escrow) > 0 && (
<p className="text-xs text-gray-500">+ ${Number(user.escrow).toFixed(2)} escrow</p>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
<p className="text-gray-900">
{user.wins}W / {user.losses}L
</p>
<p className="text-gray-500">{(user.win_rate * 100).toFixed(0)}% win rate</p>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
onClick={() => {
setSelectedUser(user)
setShowBalanceModal(true)
}}
className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded"
title="Adjust Balance"
>
<DollarSign size={18} />
</button>
<button
onClick={() =>
statusMutation.mutate({
userId: user.id,
status: user.status === 'active' ? 'suspended' : 'active',
})
}
className={`p-1.5 rounded ${
user.status === 'active'
? 'text-gray-500 hover:text-red-600 hover:bg-red-50'
: 'text-gray-500 hover:text-green-600 hover:bg-green-50'
}`}
title={user.status === 'active' ? 'Suspend User' : 'Activate User'}
>
<Ban size={18} />
</button>
<button
onClick={() =>
adminMutation.mutate({
userId: user.id,
isAdmin: !user.is_admin,
})
}
className={`p-1.5 rounded ${
user.is_admin
? 'text-blue-500 hover:text-gray-600 hover:bg-gray-50'
: 'text-gray-500 hover:text-blue-600 hover:bg-blue-50'
}`}
title={user.is_admin ? 'Remove Admin' : 'Make Admin'}
>
<Shield size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
<p className="text-sm text-gray-600">
Showing {(page - 1) * 20 + 1} to {Math.min(page * 20, data?.total || 0)} of {data?.total || 0} users
</p>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
<ChevronLeft size={18} />
</Button>
<Button
variant="secondary"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
>
<ChevronRight size={18} />
</Button>
</div>
</div>
)}
</div>
{/* Balance Adjustment Modal */}
{showBalanceModal && selectedUser && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Adjust Balance</h3>
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">User: <span className="font-medium">{selectedUser.username}</span></p>
<p className="text-sm text-gray-600">Current Balance: <span className="font-medium">${Number(selectedUser.balance).toFixed(2)}</span></p>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-gray-700 font-medium mb-1">Amount (+ to add, - to subtract)</label>
<input
type="number"
value={balanceAmount}
onChange={(e) => setBalanceAmount(e.target.value)}
placeholder="e.g., 100 or -50"
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 font-medium mb-1">Reason</label>
<textarea
value={balanceReason}
onChange={(e) => setBalanceReason(e.target.value)}
placeholder="Reason for adjustment..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => {
setShowBalanceModal(false)
setSelectedUser(null)
setBalanceAmount('')
setBalanceReason('')
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleBalanceSubmit}
disabled={!balanceAmount || !balanceReason || balanceMutation.isPending}
className="flex-1"
>
{balanceMutation.isPending ? 'Adjusting...' : 'Adjust Balance'}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -12,7 +12,8 @@ import {
Rocket,
Share2,
Settings,
Receipt
Receipt,
Shield
} from 'lucide-react'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
import { Button } from '@/components/common/Button'
@ -153,6 +154,16 @@ function ProfileDropdown() {
<Wallet size={16} />
Wallet
</Link>
{user.is_admin && (
<Link
to="/admin"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-blue-600 hover:bg-blue-50 transition-colors"
>
<Shield size={16} />
Admin Panel
</Link>
)}
</div>
{/* Web3 Wallet section */}

View File

@ -1,15 +1,271 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Loading } from '@/components/common/Loading'
import { adminApi } from '@/api/admin'
import { AdminDataTools } from '@/components/admin/AdminDataTools'
import { AdminSimulation } from '@/components/admin/AdminSimulation'
import { AdminUsers } from '@/components/admin/AdminUsers'
import { AdminAuditLog } from '@/components/admin/AdminAuditLog'
import {
LayoutDashboard,
Users,
Calendar,
Database,
Activity,
ClipboardList,
Settings,
TrendingUp,
DollarSign,
Percent,
} from 'lucide-react'
type TabType = 'dashboard' | 'users' | 'events' | 'data' | 'simulation' | 'audit'
export const Admin = () => {
const [activeTab, setActiveTab] = useState<TabType>('dashboard')
const { data: dashboard, isLoading: isDashboardLoading } = useQuery({
queryKey: ['admin-dashboard'],
queryFn: adminApi.getDashboard,
})
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: adminApi.getSettings,
})
const tabs: { id: TabType; label: string; icon: React.ReactNode }[] = [
{ id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={18} /> },
{ id: 'users', label: 'Users', icon: <Users size={18} /> },
{ id: 'events', label: 'Events', icon: <Calendar size={18} /> },
{ id: 'data', label: 'Data Tools', icon: <Database size={18} /> },
{ id: 'simulation', label: 'Simulation', icon: <Activity size={18} /> },
{ id: 'audit', label: 'Audit Log', icon: <ClipboardList size={18} /> },
]
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Panel</h1>
<p className="text-gray-600 mt-1">Manage the H2H platform</p>
</div>
{dashboard?.simulation_running && (
<div className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-lg">
<Activity size={18} className="animate-pulse" />
<span className="font-medium">Simulation Running</span>
</div>
)}
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="flex border-b overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === tab.id
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
{/* Tab Content */}
<div>
{activeTab === 'dashboard' && (
<DashboardTab dashboard={dashboard} settings={settings} isLoading={isDashboardLoading} />
)}
{activeTab === 'users' && <AdminUsers />}
{activeTab === 'events' && <EventsTab />}
{activeTab === 'data' && <AdminDataTools />}
{activeTab === 'simulation' && <AdminSimulation />}
{activeTab === 'audit' && <AdminAuditLog />}
</div>
</div>
</Layout>
)
}
// Dashboard Tab Component
const DashboardTab = ({
dashboard,
settings,
isLoading,
}: {
dashboard: any
settings: any
isLoading: boolean
}) => {
if (isLoading) {
return <Loading />
}
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="Total Users"
value={dashboard?.total_users || 0}
subtitle={`${dashboard?.active_users || 0} active`}
icon={<Users className="text-blue-500" size={24} />}
/>
<StatCard
title="Total Events"
value={dashboard?.total_events || 0}
subtitle={`${dashboard?.upcoming_events || 0} upcoming`}
icon={<Calendar className="text-green-500" size={24} />}
/>
<StatCard
title="Total Bets"
value={dashboard?.total_bets || 0}
subtitle={`${dashboard?.open_bets || 0} open, ${dashboard?.matched_bets || 0} matched`}
icon={<TrendingUp className="text-purple-500" size={24} />}
/>
<StatCard
title="Total Volume"
value={`$${(dashboard?.total_volume || 0).toLocaleString()}`}
subtitle={`$${(dashboard?.escrow_locked || 0).toLocaleString()} in escrow`}
icon={<DollarSign className="text-yellow-500" size={24} />}
/>
</div>
{/* Settings */}
{settings && (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Settings className="text-gray-400" size={24} />
<h2 className="text-xl font-semibold text-gray-900">Platform Settings</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 text-gray-600 text-sm mb-1">
<Percent size={14} />
<span>House Commission</span>
</div>
<p className="text-xl font-bold text-gray-900">{settings.default_house_commission_percent}%</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm mb-1">Min Bet</p>
<p className="text-xl font-bold text-gray-900">${settings.default_min_bet_amount}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm mb-1">Max Bet</p>
<p className="text-xl font-bold text-gray-900">${settings.default_max_bet_amount}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm mb-1">Spread Range</p>
<p className="text-xl font-bold text-gray-900">
{settings.default_min_spread} to {settings.default_max_spread}
</p>
</div>
</div>
</div>
)}
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">User Status</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Active</span>
<span className="font-semibold text-green-600">{dashboard?.active_users || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Suspended</span>
<span className="font-semibold text-red-600">{dashboard?.suspended_users || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Admins</span>
<span className="font-semibold text-blue-600">{dashboard?.admin_users || 0}</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Event Status</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Upcoming</span>
<span className="font-semibold text-blue-600">{dashboard?.upcoming_events || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Live</span>
<span className="font-semibold text-green-600">{dashboard?.live_events || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Total</span>
<span className="font-semibold text-gray-900">{dashboard?.total_events || 0}</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Bet Status</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Open</span>
<span className="font-semibold text-yellow-600">{dashboard?.open_bets || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Matched</span>
<span className="font-semibold text-green-600">{dashboard?.matched_bets || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Total</span>
<span className="font-semibold text-gray-900">{dashboard?.total_bets || 0}</span>
</div>
</div>
</div>
</div>
</div>
)
}
// Stat Card Component
const StatCard = ({
title,
value,
subtitle,
icon,
}: {
title: string
value: string | number
subtitle: string
icon: React.ReactNode
}) => (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-gray-600 text-sm">{title}</span>
{icon}
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
</div>
)
// Events Tab Component (keeping existing functionality)
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'
import { Loading } from '@/components/common/Loading'
import { adminApi, type CreateEventData } from '@/api/admin'
import { SportType } from '@/types/sport-event'
import { toast } from 'react-hot-toast'
import { Plus, Trash2 } from 'lucide-react'
import type { CreateEventData } from '@/api/admin'
export const Admin = () => {
const EventsTab = () => {
const queryClient = useQueryClient()
const [showCreateForm, setShowCreateForm] = useState(false)
const [formData, setFormData] = useState<CreateEventData>({
@ -22,11 +278,6 @@ export const Admin = () => {
league: '',
})
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: adminApi.getSettings,
})
const { data: events, isLoading } = useQuery({
queryKey: ['admin-events'],
queryFn: () => adminApi.getEvents(),
@ -38,6 +289,7 @@ export const Admin = () => {
toast.success('Event created successfully!')
queryClient.invalidateQueries({ queryKey: ['admin-events'] })
queryClient.invalidateQueries({ queryKey: ['sport-events'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
setShowCreateForm(false)
setFormData({
sport: SportType.FOOTBALL,
@ -60,6 +312,7 @@ export const Admin = () => {
toast.success('Event deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['admin-events'] })
queryClient.invalidateQueries({ queryKey: ['sport-events'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to delete event')
@ -78,207 +331,168 @@ export const Admin = () => {
}
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Panel</h1>
<p className="text-gray-600 mt-2">Manage sporting events and platform settings</p>
</div>
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus size={20} className="mr-2" />
{showCreateForm ? 'Cancel' : 'Create Event'}
</Button>
<div className="space-y-6">
{/* Create Button */}
<div className="flex justify-end">
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus size={20} className="mr-2" />
{showCreateForm ? 'Cancel' : 'Create Event'}
</Button>
</div>
{/* Create Form */}
{showCreateForm && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Create New Event</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sport</label>
<select
value={formData.sport}
onChange={(e) => setFormData({ ...formData, sport: e.target.value as SportType })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
{Object.values(SportType).map((sport) => (
<option key={sport} value={sport}>
{sport.charAt(0).toUpperCase() + sport.slice(1)}
</option>
))}
</select>
</div>
<Input
label="League"
value={formData.league || ''}
onChange={(e) => setFormData({ ...formData, league: e.target.value })}
placeholder="e.g., NFL, NBA, NCAA"
/>
<Input
label="Home Team"
value={formData.home_team}
onChange={(e) => setFormData({ ...formData, home_team: e.target.value })}
required
/>
<Input
label="Away Team"
value={formData.away_team}
onChange={(e) => setFormData({ ...formData, away_team: e.target.value })}
required
/>
<Input
label="Official Spread"
type="number"
step="0.5"
value={formData.official_spread}
onChange={(e) => setFormData({ ...formData, official_spread: parseFloat(e.target.value) })}
required
/>
<Input
label="Game Time"
type="datetime-local"
value={formData.game_time}
onChange={(e) => setFormData({ ...formData, game_time: e.target.value })}
required
/>
<Input
label="Venue"
value={formData.venue || ''}
onChange={(e) => setFormData({ ...formData, venue: e.target.value })}
placeholder="Stadium/Arena name"
/>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateForm(false)}
disabled={createEventMutation.isPending}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" disabled={createEventMutation.isPending} className="flex-1">
{createEventMutation.isPending ? 'Creating...' : 'Create Event'}
</Button>
</div>
</form>
</div>
)}
{/* Events List */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold">All Events</h2>
</div>
{settings && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Platform Settings</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-600">House Commission</p>
<p className="font-semibold">{settings.default_house_commission_percent}%</p>
</div>
<div>
<p className="text-gray-600">Min Bet</p>
<p className="font-semibold">${settings.default_min_bet_amount}</p>
</div>
<div>
<p className="text-gray-600">Max Bet</p>
<p className="font-semibold">${settings.default_max_bet_amount}</p>
</div>
<div>
<p className="text-gray-600">Spread Range</p>
<p className="font-semibold">
{settings.default_min_spread} to {settings.default_max_spread}
</p>
</div>
</div>
{isLoading ? (
<div className="p-6">
<Loading />
</div>
)}
{showCreateForm && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Create New Event</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sport
</label>
<select
value={formData.sport}
onChange={(e) =>
setFormData({ ...formData, sport: e.target.value as SportType })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
{Object.values(SportType).map((sport) => (
<option key={sport} value={sport}>
{sport.charAt(0).toUpperCase() + sport.slice(1)}
</option>
))}
</select>
</div>
<Input
label="League"
value={formData.league || ''}
onChange={(e) => setFormData({ ...formData, league: e.target.value })}
placeholder="e.g., NFL, NBA, NCAA"
/>
<Input
label="Home Team"
value={formData.home_team}
onChange={(e) => setFormData({ ...formData, home_team: e.target.value })}
required
/>
<Input
label="Away Team"
value={formData.away_team}
onChange={(e) => setFormData({ ...formData, away_team: e.target.value })}
required
/>
<Input
label="Official Spread"
type="number"
step="0.5"
value={formData.official_spread}
onChange={(e) =>
setFormData({ ...formData, official_spread: parseFloat(e.target.value) })
}
required
/>
<Input
label="Game Time"
type="datetime-local"
value={formData.game_time}
onChange={(e) => setFormData({ ...formData, game_time: e.target.value })}
required
/>
<Input
label="Venue"
value={formData.venue || ''}
onChange={(e) => setFormData({ ...formData, venue: e.target.value })}
placeholder="Stadium/Arena name"
/>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateForm(false)}
disabled={createEventMutation.isPending}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={createEventMutation.isPending}
className="flex-1"
>
{createEventMutation.isPending ? 'Creating...' : 'Create Event'}
</Button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold">All Events</h2>
</div>
{isLoading ? (
<div className="p-6">
<Loading />
</div>
) : !events || events.length === 0 ? (
<div className="p-6 text-center text-gray-600">No events created yet</div>
) : (
<div className="divide-y divide-gray-200">
{events.map((event) => (
<div key={event.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-semibold uppercase">
{event.sport}
</span>
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
{event.status}
</span>
</div>
<h3 className="font-bold text-lg text-gray-900">
{event.home_team} vs {event.away_team}
</h3>
<div className="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
<div>
<span className="font-medium">Spread:</span> {event.home_team}{' '}
{event.official_spread > 0 ? '+' : ''}
{event.official_spread}
</div>
<div>
<span className="font-medium">Time:</span>{' '}
{new Date(event.game_time).toLocaleString()}
</div>
{event.venue && (
<div>
<span className="font-medium">Venue:</span> {event.venue}
</div>
)}
{event.league && (
<div>
<span className="font-medium">League:</span> {event.league}
</div>
)}
</div>
) : !events || events.length === 0 ? (
<div className="p-6 text-center text-gray-600">No events created yet</div>
) : (
<div className="divide-y divide-gray-200">
{events.map((event) => (
<div key={event.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-semibold uppercase">
{event.sport}
</span>
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
{event.status}
</span>
</div>
<Button
variant="secondary"
onClick={() => handleDelete(event.id)}
disabled={deleteEventMutation.isPending}
className="ml-4"
>
<Trash2 size={18} />
</Button>
<h3 className="font-bold text-lg text-gray-900">
{event.home_team} vs {event.away_team}
</h3>
<div className="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
<div>
<span className="font-medium">Spread:</span> {event.home_team}{' '}
{event.official_spread > 0 ? '+' : ''}
{event.official_spread}
</div>
<div>
<span className="font-medium">Time:</span>{' '}
{new Date(event.game_time).toLocaleString()}
</div>
{event.venue && (
<div>
<span className="font-medium">Venue:</span> {event.venue}
</div>
)}
{event.league && (
<div>
<span className="font-medium">League:</span> {event.league}
</div>
)}
</div>
</div>
<Button
variant="secondary"
onClick={() => handleDelete(event.id)}
disabled={deleteEventMutation.isPending}
className="ml-4"
>
<Trash2 size={18} />
</Button>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</Layout>
</div>
)
}

178
frontend/src/types/admin.ts Normal file
View File

@ -0,0 +1,178 @@
// Admin Panel Types
export interface AdminDashboardStats {
total_users: number
active_users: number
suspended_users: number
admin_users: number
total_events: number
upcoming_events: number
live_events: number
total_bets: number
open_bets: number
matched_bets: number
total_volume: number
escrow_locked: number
simulation_running: boolean
}
export interface AuditLog {
id: number
admin_id: number
admin_username: string
action: string
target_type: string | null
target_id: number | null
description: string
details: string | null
ip_address: string | null
created_at: string
}
export interface AuditLogListResponse {
logs: AuditLog[]
total: number
page: number
page_size: number
}
export interface WipePreview {
users_count: number
wallets_count: number
transactions_count: number
bets_count: number
spread_bets_count: number
events_count: number
event_comments_count: number
match_comments_count: number
admin_settings_preserved: boolean
admin_users_preserved: boolean
can_wipe: boolean
cooldown_remaining_seconds: number
last_wipe_at: string | null
}
export interface WipeRequest {
confirmation_phrase: string
preserve_admin_users: boolean
preserve_events: boolean
}
export interface WipeResponse {
success: boolean
message: string
deleted_counts: Record<string, number>
preserved_counts: Record<string, number>
executed_at: string
executed_by: string
}
export interface SeedRequest {
num_users: number
num_events: number
num_bets_per_event: number
starting_balance: number
create_admin: boolean
}
export interface SeedResponse {
success: boolean
message: string
created_counts: Record<string, number>
test_admin: {
username: string
email: string
password: string
} | null
}
export interface SimulationConfig {
delay_seconds: number
actions_per_iteration: number
create_users: boolean
create_bets: boolean
take_bets: boolean
add_comments: boolean
cancel_bets: boolean
}
export interface SimulationStatus {
is_running: boolean
started_at: string | null
started_by: string | null
iterations_completed: number
config: SimulationConfig | null
last_activity: string | null
}
export interface SimulationStartResponse {
success: boolean
message: string
status: SimulationStatus
}
export interface SimulationStopResponse {
success: boolean
message: string
total_iterations: number
ran_for_seconds: number
}
export interface AdminUser {
id: number
email: string
username: string
display_name: string | null
is_admin: boolean
status: 'active' | 'suspended' | 'pending_verification'
balance: number
escrow: number
total_bets: number
wins: number
losses: number
win_rate: number
created_at: string
}
export interface AdminUserListResponse {
users: AdminUser[]
total: number
page: number
page_size: number
}
export interface AdminUserDetail extends AdminUser {
avatar_url: string | null
bio: string | null
updated_at: string
open_bets_count: number
matched_bets_count: number
transaction_count: number
}
export interface UserUpdateRequest {
display_name?: string
email?: string
is_admin?: boolean
}
export interface UserStatusRequest {
status: 'active' | 'suspended'
reason?: string
}
export interface BalanceAdjustRequest {
amount: number
reason: string
}
export interface BalanceAdjustResponse {
success: boolean
user_id: number
username: string
previous_balance: number
adjustment: number
new_balance: number
reason: string
transaction_id: number
}

View File

@ -1,5 +1,37 @@
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000'
// Auto-detect API URL based on environment
const getApiUrl = () => {
// If explicitly set via env var, use that
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL
}
// In production (not localhost), use the current origin
if (typeof window !== 'undefined' && !window.location.hostname.includes('localhost')) {
return window.location.origin
}
// Default for local development
return 'http://localhost:8000'
}
const getWsUrl = () => {
// If explicitly set via env var, use that
if (import.meta.env.VITE_WS_URL) {
return import.meta.env.VITE_WS_URL
}
// In production (not localhost), use the current origin with ws/wss protocol
if (typeof window !== 'undefined' && !window.location.hostname.includes('localhost')) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}`
}
// Default for local development
return 'ws://localhost:8000'
}
export const API_URL = getApiUrl()
export const WS_URL = getWsUrl()
export const BET_CATEGORIES = {
sports: 'Sports',

View File

@ -1,6 +1,47 @@
{
"status": "failed",
"failedTests": [
"6d68a41ba2df42f4b38a-e2ca0c03202a119dcb00"
"399d6696af02825fcc54-fc888ff42dc2b30aa82b",
"399d6696af02825fcc54-b35cbe9df9b8ee6fbb68",
"399d6696af02825fcc54-4678e6a61ad56aeba576",
"399d6696af02825fcc54-798ef6c3a30524cf250c",
"399d6696af02825fcc54-70b131339b56e3e3bcb2",
"399d6696af02825fcc54-8a2f777ef0124afa96f8",
"399d6696af02825fcc54-1e41b9644ef30adb45b5",
"399d6696af02825fcc54-5b7d0c4e8f02bb47a409",
"399d6696af02825fcc54-5774835752036b7773c4",
"399d6696af02825fcc54-5f528e0c4f2ca6c2594b",
"399d6696af02825fcc54-2fe972eb375fef289cfb",
"399d6696af02825fcc54-0907f1ea60f935008b5d",
"399d6696af02825fcc54-fa647ad6129724f04bf8",
"399d6696af02825fcc54-bac61ab84e1e79ee6ba3",
"399d6696af02825fcc54-f501d3049aec3e74df6a",
"399d6696af02825fcc54-d4545dadcdb59e4d0d04",
"399d6696af02825fcc54-6d433c9da420baad420f",
"399d6696af02825fcc54-e18b5c250391615c5840",
"399d6696af02825fcc54-7ba813b673c11d614ec1",
"399d6696af02825fcc54-410210b4864035dcc50c",
"399d6696af02825fcc54-75ff2ee4b434dafaf053",
"399d6696af02825fcc54-b16b89956314f85d038b",
"399d6696af02825fcc54-867a47ad3feb8c1ea989",
"399d6696af02825fcc54-0ae0f5cf730a86f39cf0",
"399d6696af02825fcc54-6f1de39d169d1d31a049",
"399d6696af02825fcc54-838660ce45c7b32936ed",
"399d6696af02825fcc54-3c4b21f9704d0c2a1fce",
"399d6696af02825fcc54-d4f26f69a16d273ab350",
"399d6696af02825fcc54-e214d4bea02392decbfe",
"399d6696af02825fcc54-2bac0af8c68d3c793e23",
"399d6696af02825fcc54-4d31ccd6518596f55123",
"399d6696af02825fcc54-b49153ca2dddf3c9ccf0",
"399d6696af02825fcc54-5f96348bcf3e593ab75c",
"399d6696af02825fcc54-b6242557d6ec16657497",
"399d6696af02825fcc54-bf8f07af1535ceecf134",
"399d6696af02825fcc54-1391aed50a1c4ddc33a1",
"399d6696af02825fcc54-0837f0b0a069d56b673b",
"399d6696af02825fcc54-3deeadf799377f2a9506",
"399d6696af02825fcc54-a3472daaa19e2587e35f",
"399d6696af02825fcc54-f0a60c55507432822701",
"399d6696af02825fcc54-f425b02fdf0761d40761",
"399d6696af02825fcc54-6e0a0ed7a5af9a00de6b"
]
}

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,21 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- heading "H2H" [level=1] [ref=e6]
- heading "Login to your account" [level=2] [ref=e7]
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]: Email
- textbox "Email" [ref=e12]
- generic [ref=e13]:
- generic [ref=e14]: Password
- textbox "Password" [ref=e15]
- button "Login" [ref=e16] [cursor=pointer]
- paragraph [ref=e18]:
- text: Don't have an account?
- link "Register here" [ref=e19] [cursor=pointer]:
- /url: /register
```

Some files were not shown because too many files have changed in this diff Show More