feat: Complete admin dashboard and improved navigation
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Navigation Header:
- Playfair Display font for 'pounce' logo (explicit style)
- Perfect vertical centering with h-full on containers
- Consistent text sizes (0.8125rem / 13px)
Admin Dashboard (/admin):
- Overview tab: User stats, subscription breakdown, quick actions
- Users tab: Search, list, upgrade tier, toggle admin
- Newsletter tab: List subscribers, export emails
- TLD tab: Price data stats, trigger manual scrape
- System tab: Health status, environment info
Backend Admin API:
- Added is_admin field to User model
- Admin authentication via require_admin dependency
- GET /admin/stats - Dashboard statistics
- GET/PATCH/DELETE /admin/users - User management
- POST /admin/users/{id}/upgrade - Tier upgrades
- GET /admin/newsletter - Subscriber list
- GET /admin/newsletter/export - Email export
- POST /admin/scrape-tld-prices - Manual TLD scrape
- GET /admin/tld-prices/stats - TLD data statistics
- GET /admin/system/health - System health check
- POST /admin/system/make-admin - Initial admin setup
Frontend API:
- Added AdminApiClient with all admin endpoints
- getAdminStats, getAdminUsers, updateAdminUser
- upgradeUser, getAdminNewsletter, exportNewsletterEmails
- triggerTldScrape, getSystemHealth, makeUserAdmin
This commit is contained in:
@ -1,32 +1,472 @@
|
||||
"""Admin API endpoints - for internal use only."""
|
||||
from fastapi import APIRouter, HTTPException, status, BackgroundTasks
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
"""
|
||||
Admin API endpoints for pounce administration.
|
||||
|
||||
from app.api.deps import Database
|
||||
Provides admin-only access to:
|
||||
- User management
|
||||
- TLD price scraping
|
||||
- System statistics
|
||||
- Newsletter management
|
||||
- Domain/Portfolio overview
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Depends
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.api.deps import Database, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
||||
from app.models.domain import Domain
|
||||
from app.models.portfolio import PortfolioDomain
|
||||
from app.models.newsletter import NewsletterSubscriber
|
||||
from app.models.tld_price import TLDPrice, TLDInfo
|
||||
from app.models.auction import DomainAuction
|
||||
from app.models.price_alert import PriceAlert
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============== Admin Authentication ==============
|
||||
|
||||
async def require_admin(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Dependency that requires admin privileges."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# ============== Dashboard Stats ==============
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Get comprehensive admin dashboard statistics."""
|
||||
|
||||
# User stats
|
||||
total_users = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_users.scalar()
|
||||
|
||||
active_users = await db.execute(
|
||||
select(func.count(User.id)).where(User.is_active == True)
|
||||
)
|
||||
active_users = active_users.scalar()
|
||||
|
||||
verified_users = await db.execute(
|
||||
select(func.count(User.id)).where(User.is_verified == True)
|
||||
)
|
||||
verified_users = verified_users.scalar()
|
||||
|
||||
# New users last 7 days
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
new_users_week = await db.execute(
|
||||
select(func.count(User.id)).where(User.created_at >= week_ago)
|
||||
)
|
||||
new_users_week = new_users_week.scalar()
|
||||
|
||||
# Subscription stats
|
||||
subscriptions_by_tier = {}
|
||||
for tier in SubscriptionTier:
|
||||
count = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.tier == tier,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
subscriptions_by_tier[tier.value] = count.scalar()
|
||||
|
||||
# Domain stats
|
||||
total_domains = await db.execute(select(func.count(Domain.id)))
|
||||
total_domains = total_domains.scalar()
|
||||
|
||||
total_portfolio = await db.execute(select(func.count(PortfolioDomain.id)))
|
||||
total_portfolio = total_portfolio.scalar()
|
||||
|
||||
# TLD stats
|
||||
total_tlds = await db.execute(select(func.count(func.distinct(TLDPrice.tld))))
|
||||
total_tlds = total_tlds.scalar()
|
||||
|
||||
total_price_records = await db.execute(select(func.count(TLDPrice.id)))
|
||||
total_price_records = total_price_records.scalar()
|
||||
|
||||
# Newsletter stats
|
||||
newsletter_subscribers = await db.execute(
|
||||
select(func.count(NewsletterSubscriber.id)).where(NewsletterSubscriber.is_active == True)
|
||||
)
|
||||
newsletter_subscribers = newsletter_subscribers.scalar()
|
||||
|
||||
# Auction stats
|
||||
total_auctions = await db.execute(select(func.count(DomainAuction.id)))
|
||||
total_auctions = total_auctions.scalar()
|
||||
|
||||
# Price alerts
|
||||
total_alerts = await db.execute(
|
||||
select(func.count(PriceAlert.id)).where(PriceAlert.is_active == True)
|
||||
)
|
||||
total_alerts = total_alerts.scalar()
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"verified": verified_users,
|
||||
"new_this_week": new_users_week,
|
||||
},
|
||||
"subscriptions": subscriptions_by_tier,
|
||||
"domains": {
|
||||
"watched": total_domains,
|
||||
"portfolio": total_portfolio,
|
||||
},
|
||||
"tld_data": {
|
||||
"unique_tlds": total_tlds,
|
||||
"price_records": total_price_records,
|
||||
},
|
||||
"newsletter_subscribers": newsletter_subscribers,
|
||||
"auctions": total_auctions,
|
||||
"price_alerts": total_alerts,
|
||||
}
|
||||
|
||||
|
||||
# ============== User Management ==============
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""Schema for updating user."""
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_verified: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
|
||||
|
||||
class UpgradeUserRequest(BaseModel):
|
||||
"""Request schema for upgrading a user."""
|
||||
email: EmailStr
|
||||
tier: str # scout, trader, tycoon
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
search: Optional[str] = None,
|
||||
):
|
||||
"""List all users with pagination and search."""
|
||||
query = select(User).order_by(desc(User.created_at))
|
||||
|
||||
if search:
|
||||
query = query.where(
|
||||
User.email.ilike(f"%{search}%") |
|
||||
User.name.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count(User.id))
|
||||
if search:
|
||||
count_query = count_query.where(
|
||||
User.email.ilike(f"%{search}%") |
|
||||
User.name.ilike(f"%{search}%")
|
||||
)
|
||||
total = await db.execute(count_query)
|
||||
total = total.scalar()
|
||||
|
||||
user_list = []
|
||||
for user in users:
|
||||
# Get subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
|
||||
# Get domain count
|
||||
domain_count = await db.execute(
|
||||
select(func.count(Domain.id)).where(Domain.user_id == user.id)
|
||||
)
|
||||
domain_count = domain_count.scalar()
|
||||
|
||||
user_list.append({
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
"is_verified": user.is_verified,
|
||||
"is_admin": user.is_admin,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||
"domain_count": domain_count,
|
||||
"subscription": {
|
||||
"tier": subscription.tier.value if subscription else "scout",
|
||||
"tier_name": TIER_CONFIG.get(subscription.tier, {}).get("name", "Scout") if subscription else "Scout",
|
||||
"status": subscription.status.value if subscription else None,
|
||||
"domain_limit": subscription.domain_limit if subscription else 5,
|
||||
} if subscription else {
|
||||
"tier": "scout",
|
||||
"tier_name": "Scout",
|
||||
"status": None,
|
||||
"domain_limit": 5,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
"users": user_list,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Get detailed user information."""
|
||||
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")
|
||||
|
||||
# Get subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
|
||||
# Get domains
|
||||
domains_result = await db.execute(
|
||||
select(Domain).where(Domain.user_id == user.id)
|
||||
)
|
||||
domains = domains_result.scalars().all()
|
||||
|
||||
# Get portfolio
|
||||
portfolio_result = await db.execute(
|
||||
select(PortfolioDomain).where(PortfolioDomain.user_id == user.id)
|
||||
)
|
||||
portfolio = portfolio_result.scalars().all()
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
"is_verified": user.is_verified,
|
||||
"is_admin": user.is_admin,
|
||||
"stripe_customer_id": user.stripe_customer_id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"updated_at": user.updated_at.isoformat(),
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||
"subscription": {
|
||||
"tier": subscription.tier.value if subscription else None,
|
||||
"status": subscription.status.value if subscription else None,
|
||||
"domain_limit": subscription.domain_limit if subscription else 0,
|
||||
"stripe_subscription_id": subscription.stripe_subscription_id if subscription else None,
|
||||
} if subscription else None,
|
||||
"domains": [
|
||||
{
|
||||
"id": d.id,
|
||||
"domain": d.domain,
|
||||
"status": d.status,
|
||||
"created_at": d.created_at.isoformat(),
|
||||
}
|
||||
for d in domains
|
||||
],
|
||||
"portfolio": [
|
||||
{
|
||||
"id": p.id,
|
||||
"domain": p.domain,
|
||||
"purchase_price": p.purchase_price,
|
||||
"estimated_value": p.estimated_value,
|
||||
}
|
||||
for p in portfolio
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
request: UpdateUserRequest,
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""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")
|
||||
|
||||
if request.name is not None:
|
||||
user.name = request.name
|
||||
if request.is_active is not None:
|
||||
user.is_active = request.is_active
|
||||
if request.is_verified is not None:
|
||||
user.is_verified = request.is_verified
|
||||
if request.is_admin is not None:
|
||||
user.is_admin = request.is_admin
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return {"message": f"User {user.email} updated", "user_id": user.id}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user and all their data."""
|
||||
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")
|
||||
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
return {"message": f"User {user.email} deleted"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/upgrade")
|
||||
async def upgrade_user(
|
||||
user_id: int,
|
||||
tier: str,
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Upgrade a user's subscription tier."""
|
||||
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")
|
||||
|
||||
# Validate tier
|
||||
try:
|
||||
new_tier = SubscriptionTier(tier)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid tier: {tier}. Valid: scout, trader, tycoon"
|
||||
)
|
||||
|
||||
# Find or create subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
|
||||
config = TIER_CONFIG.get(new_tier, {})
|
||||
|
||||
if not subscription:
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=new_tier,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
domain_limit=config.get("domain_limit", 5),
|
||||
)
|
||||
db.add(subscription)
|
||||
else:
|
||||
subscription.tier = new_tier
|
||||
subscription.domain_limit = config.get("domain_limit", 5)
|
||||
subscription.status = SubscriptionStatus.ACTIVE
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} upgraded to {config.get('name', tier)}",
|
||||
"tier": new_tier.value,
|
||||
}
|
||||
|
||||
|
||||
# ============== Newsletter Management ==============
|
||||
|
||||
@router.get("/newsletter")
|
||||
async def list_newsletter_subscribers(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
active_only: bool = True,
|
||||
):
|
||||
"""List newsletter subscribers."""
|
||||
query = select(NewsletterSubscriber).order_by(desc(NewsletterSubscriber.subscribed_at))
|
||||
|
||||
if active_only:
|
||||
query = query.where(NewsletterSubscriber.is_active == True)
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
subscribers = result.scalars().all()
|
||||
|
||||
# Total count
|
||||
count_query = select(func.count(NewsletterSubscriber.id))
|
||||
if active_only:
|
||||
count_query = count_query.where(NewsletterSubscriber.is_active == True)
|
||||
total = await db.execute(count_query)
|
||||
total = total.scalar()
|
||||
|
||||
return {
|
||||
"subscribers": [
|
||||
{
|
||||
"id": s.id,
|
||||
"email": s.email,
|
||||
"is_active": s.is_active,
|
||||
"subscribed_at": s.subscribed_at.isoformat(),
|
||||
"unsubscribed_at": s.unsubscribed_at.isoformat() if s.unsubscribed_at else None,
|
||||
}
|
||||
for s in subscribers
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/newsletter/export")
|
||||
async def export_newsletter_emails(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Export all active newsletter emails (for external tools)."""
|
||||
result = await db.execute(
|
||||
select(NewsletterSubscriber.email).where(NewsletterSubscriber.is_active == True)
|
||||
)
|
||||
emails = [row[0] for row in result.fetchall()]
|
||||
|
||||
return {
|
||||
"emails": emails,
|
||||
"count": len(emails),
|
||||
}
|
||||
|
||||
|
||||
# ============== TLD Management ==============
|
||||
|
||||
@router.post("/scrape-tld-prices")
|
||||
async def trigger_tld_scrape(background_tasks: BackgroundTasks, db: Database):
|
||||
"""
|
||||
Manually trigger a TLD price scrape.
|
||||
|
||||
This runs in the background and returns immediately.
|
||||
Check logs for scrape results.
|
||||
|
||||
NOTE: In production, this should require admin authentication!
|
||||
"""
|
||||
async def trigger_tld_scrape(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Manually trigger a TLD price scrape."""
|
||||
from app.services.tld_scraper.aggregator import tld_aggregator
|
||||
|
||||
async def run_scrape():
|
||||
result = await tld_aggregator.run_scrape(db)
|
||||
return result
|
||||
|
||||
# Run synchronously for immediate feedback
|
||||
result = await tld_aggregator.run_scrape(db)
|
||||
|
||||
return {
|
||||
@ -43,34 +483,34 @@ async def trigger_tld_scrape(background_tasks: BackgroundTasks, db: Database):
|
||||
|
||||
|
||||
@router.get("/tld-prices/stats")
|
||||
async def get_tld_price_stats(db: Database):
|
||||
"""Get statistics about stored TLD price data."""
|
||||
from sqlalchemy import func
|
||||
from app.models.tld_price import TLDPrice
|
||||
|
||||
async def get_tld_price_stats(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Get TLD price data statistics."""
|
||||
# Total records
|
||||
total_result = await db.execute(select(func.count(TLDPrice.id)))
|
||||
total_records = total_result.scalar()
|
||||
total = await db.execute(select(func.count(TLDPrice.id)))
|
||||
total_records = total.scalar()
|
||||
|
||||
# Unique TLDs
|
||||
tlds_result = await db.execute(select(func.count(func.distinct(TLDPrice.tld))))
|
||||
unique_tlds = tlds_result.scalar()
|
||||
tlds = await db.execute(select(func.count(func.distinct(TLDPrice.tld))))
|
||||
unique_tlds = tlds.scalar()
|
||||
|
||||
# Unique registrars
|
||||
registrars_result = await db.execute(select(func.count(func.distinct(TLDPrice.registrar))))
|
||||
unique_registrars = registrars_result.scalar()
|
||||
registrars = await db.execute(select(func.count(func.distinct(TLDPrice.registrar))))
|
||||
unique_registrars = registrars.scalar()
|
||||
|
||||
# Latest record
|
||||
latest_result = await db.execute(
|
||||
select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.desc()).limit(1)
|
||||
latest = await db.execute(
|
||||
select(TLDPrice.recorded_at).order_by(desc(TLDPrice.recorded_at)).limit(1)
|
||||
)
|
||||
latest_record = latest_result.scalar()
|
||||
latest_record = latest.scalar()
|
||||
|
||||
# Oldest record
|
||||
oldest_result = await db.execute(
|
||||
oldest = await db.execute(
|
||||
select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.asc()).limit(1)
|
||||
)
|
||||
oldest_record = oldest_result.scalar()
|
||||
oldest_record = oldest.scalar()
|
||||
|
||||
return {
|
||||
"total_records": total_records,
|
||||
@ -82,108 +522,55 @@ async def get_tld_price_stats(db: Database):
|
||||
}
|
||||
|
||||
|
||||
class UpgradeUserRequest(BaseModel):
|
||||
"""Request schema for upgrading a user."""
|
||||
email: EmailStr
|
||||
tier: str # starter, professional, enterprise
|
||||
# ============== System ==============
|
||||
|
||||
|
||||
@router.post("/upgrade-user")
|
||||
async def upgrade_user(request: UpgradeUserRequest, db: Database):
|
||||
"""
|
||||
Upgrade a user's subscription tier.
|
||||
|
||||
NOTE: In production, this should require admin authentication!
|
||||
"""
|
||||
# Find user
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == request.email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with email {request.email} not found"
|
||||
)
|
||||
|
||||
# Validate tier
|
||||
@router.get("/system/health")
|
||||
async def system_health(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Check system health."""
|
||||
# Test database
|
||||
try:
|
||||
new_tier = SubscriptionTier(request.tier)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid tier: {request.tier}. Valid options: starter, professional, enterprise"
|
||||
)
|
||||
await db.execute(select(1))
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
# Find or create subscription
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
# Check email config
|
||||
from app.services.email_service import EmailService
|
||||
email_configured = EmailService.is_configured()
|
||||
|
||||
if not subscription:
|
||||
# Create new subscription
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=new_tier,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
domain_limit=TIER_CONFIG[new_tier]["domain_limit"],
|
||||
)
|
||||
db.add(subscription)
|
||||
else:
|
||||
# Update existing
|
||||
subscription.tier = new_tier
|
||||
subscription.domain_limit = TIER_CONFIG[new_tier]["domain_limit"]
|
||||
subscription.status = SubscriptionStatus.ACTIVE
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
config = TIER_CONFIG[new_tier]
|
||||
# Check Stripe config
|
||||
import os
|
||||
stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY"))
|
||||
|
||||
return {
|
||||
"message": f"User {request.email} upgraded to {config['name']}",
|
||||
"user_id": user.id,
|
||||
"tier": new_tier.value,
|
||||
"tier_name": config["name"],
|
||||
"domain_limit": config["domain_limit"],
|
||||
"features": config["features"],
|
||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||
"database": db_status,
|
||||
"email_configured": email_configured,
|
||||
"stripe_configured": stripe_configured,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(db: Database, limit: int = 50, offset: int = 0):
|
||||
@router.post("/system/make-admin")
|
||||
async def make_user_admin(
|
||||
email: str,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
List all users with their subscriptions.
|
||||
|
||||
NOTE: In production, this should require admin authentication!
|
||||
Make a user an admin.
|
||||
NOTE: This endpoint has NO authentication for initial setup!
|
||||
In production, disable after first admin is created.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).offset(offset).limit(limit)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
user_list = []
|
||||
for user in users:
|
||||
# Get subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
|
||||
user_list.append({
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"subscription": {
|
||||
"tier": subscription.tier.value if subscription else None,
|
||||
"status": subscription.status.value if subscription else None,
|
||||
"domain_limit": subscription.domain_limit if subscription else 0,
|
||||
} if subscription else None,
|
||||
})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {"users": user_list, "count": len(user_list)}
|
||||
|
||||
user.is_admin = True
|
||||
await db.commit()
|
||||
|
||||
return {"message": f"User {email} is now an admin"}
|
||||
|
||||
@ -25,6 +25,7 @@ class User(Base):
|
||||
# Status
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Password Reset
|
||||
password_reset_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
635
frontend/src/app/admin/page.tsx
Normal file
635
frontend/src/app/admin/page.tsx
Normal file
@ -0,0 +1,635 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
Users,
|
||||
Database,
|
||||
Mail,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Crown,
|
||||
Zap,
|
||||
Activity,
|
||||
Globe,
|
||||
Bell,
|
||||
Briefcase,
|
||||
AlertCircle,
|
||||
Check,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type TabType = 'overview' | 'users' | 'newsletter' | 'tld' | 'system'
|
||||
|
||||
interface AdminStats {
|
||||
users: {
|
||||
total: number
|
||||
active: number
|
||||
verified: number
|
||||
new_this_week: number
|
||||
}
|
||||
subscriptions: Record<string, number>
|
||||
domains: {
|
||||
watched: number
|
||||
portfolio: number
|
||||
}
|
||||
tld_data: {
|
||||
unique_tlds: number
|
||||
price_records: number
|
||||
}
|
||||
newsletter_subscribers: number
|
||||
auctions: number
|
||||
price_alerts: number
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: number
|
||||
email: string
|
||||
name: string | null
|
||||
is_active: boolean
|
||||
is_verified: boolean
|
||||
is_admin: boolean
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
domain_count: number
|
||||
subscription: {
|
||||
tier: string
|
||||
tier_name: string
|
||||
status: string | null
|
||||
domain_limit: number
|
||||
}
|
||||
}
|
||||
|
||||
interface NewsletterSubscriber {
|
||||
id: number
|
||||
email: string
|
||||
is_active: boolean
|
||||
subscribed_at: string
|
||||
unsubscribed_at: string | null
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading, checkAuth } = useStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [usersTotal, setUsersTotal] = useState(0)
|
||||
const [newsletter, setNewsletter] = useState<NewsletterSubscriber[]>([])
|
||||
const [newsletterTotal, setNewsletterTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [scraping, setScraping] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadAdminData()
|
||||
}
|
||||
}, [isAuthenticated, activeTab])
|
||||
|
||||
const loadAdminData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (activeTab === 'overview') {
|
||||
const statsData = await api.getAdminStats()
|
||||
setStats(statsData)
|
||||
} else if (activeTab === 'users') {
|
||||
const usersData = await api.getAdminUsers(50, 0, searchQuery || undefined)
|
||||
setUsers(usersData.users)
|
||||
setUsersTotal(usersData.total)
|
||||
} else if (activeTab === 'newsletter') {
|
||||
const nlData = await api.getAdminNewsletter(100, 0)
|
||||
setNewsletter(nlData.subscribers)
|
||||
setNewsletterTotal(nlData.total)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('403')) {
|
||||
setError('Admin privileges required. You are not authorized to access this page.')
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load admin data')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerScrape = async () => {
|
||||
setScraping(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await api.triggerTldScrape()
|
||||
setSuccess(`Scrape completed: ${result.tlds_scraped} TLDs, ${result.prices_saved} prices saved`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Scrape failed')
|
||||
} finally {
|
||||
setScraping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpgradeUser = async (userId: number, tier: string) => {
|
||||
try {
|
||||
await api.upgradeUser(userId, tier)
|
||||
setSuccess(`User upgraded to ${tier}`)
|
||||
loadAdminData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upgrade failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
|
||||
try {
|
||||
await api.updateAdminUser(userId, { is_admin: !isAdmin })
|
||||
setSuccess(isAdmin ? 'Admin privileges removed' : 'Admin privileges granted')
|
||||
loadAdminData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && error.includes('403')) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="text-center max-w-md">
|
||||
<Shield className="w-16 h-16 text-danger mx-auto mb-6" />
|
||||
<h1 className="text-2xl font-bold text-foreground mb-4">Access Denied</h1>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
You don't have admin privileges to access this page.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="px-6 py-3 bg-foreground text-background rounded-lg font-medium"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview' as const, label: 'Overview', icon: Activity },
|
||||
{ id: 'users' as const, label: 'Users', icon: Users },
|
||||
{ id: 'newsletter' as const, label: 'Newsletter', icon: Mail },
|
||||
{ id: 'tld' as const, label: 'TLD Data', icon: Globe },
|
||||
{ id: 'system' as const, label: 'System', icon: Database },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 pt-28 sm:pt-32 pb-16 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Shield className="w-6 h-6 text-accent" />
|
||||
<h1 className="font-display text-2xl sm:text-3xl font-bold text-foreground">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-foreground-muted">
|
||||
Manage users, monitor system health, and control platform settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{error && !error.includes('403') && (
|
||||
<div className="mb-6 p-4 bg-danger-muted border border-danger/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger shrink-0" />
|
||||
<p className="text-body-sm text-danger flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-danger hover:text-danger/80">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-accent-muted border border-accent/20 rounded-xl flex items-center gap-3">
|
||||
<Check className="w-5 h-5 text-accent shrink-0" />
|
||||
<p className="text-body-sm text-accent flex-1">{success}</p>
|
||||
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-8 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-ui-sm font-medium rounded-xl whitespace-nowrap transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-foreground text-background"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && stats && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats.users.total}
|
||||
subtitle={`${stats.users.new_this_week} new this week`}
|
||||
icon={Users}
|
||||
/>
|
||||
<StatCard
|
||||
title="Watched Domains"
|
||||
value={stats.domains.watched}
|
||||
subtitle={`${stats.domains.portfolio} in portfolios`}
|
||||
icon={Eye}
|
||||
/>
|
||||
<StatCard
|
||||
title="TLDs Tracked"
|
||||
value={stats.tld_data.unique_tlds}
|
||||
subtitle={`${stats.tld_data.price_records.toLocaleString()} price records`}
|
||||
icon={Globe}
|
||||
/>
|
||||
<StatCard
|
||||
title="Newsletter"
|
||||
value={stats.newsletter_subscribers}
|
||||
subtitle="active subscribers"
|
||||
icon={Mail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subscription Breakdown */}
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-4">Subscriptions by Tier</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-background-tertiary rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-foreground-subtle" />
|
||||
<span className="text-ui-sm text-foreground-muted">Scout</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.subscriptions.scout || 0}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-tertiary rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-accent" />
|
||||
<span className="text-ui-sm text-foreground-muted">Trader</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.subscriptions.trader || 0}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-tertiary rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Crown className="w-4 h-4 text-warning" />
|
||||
<span className="text-ui-sm text-foreground-muted">Tycoon</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.subscriptions.tycoon || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-4">Quick Actions</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={handleTriggerScrape}
|
||||
disabled={scraping}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-background rounded-lg font-medium hover:bg-accent-hover disabled:opacity-50 transition-all"
|
||||
>
|
||||
{scraping ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
{scraping ? 'Scraping...' : 'Scrape TLD Prices'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && loadAdminData()}
|
||||
placeholder="Search users by email or name..."
|
||||
className="w-full pl-11 pr-4 py-2.5 bg-background-secondary border border-border rounded-xl text-body-sm text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-background-secondary/50 border-b border-border">
|
||||
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">User</th>
|
||||
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted hidden md:table-cell">Status</th>
|
||||
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Tier</th>
|
||||
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted hidden lg:table-cell">Domains</th>
|
||||
<th className="text-right px-4 py-3 text-ui-sm font-medium text-foreground-muted">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-background-secondary/30">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">{u.email}</p>
|
||||
<p className="text-ui-sm text-foreground-subtle">{u.name || 'No name'}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
{u.is_admin && (
|
||||
<span className="px-2 py-0.5 bg-accent/20 text-accent text-ui-xs rounded-full">Admin</span>
|
||||
)}
|
||||
{u.is_verified && (
|
||||
<span className="px-2 py-0.5 bg-accent-muted text-accent text-ui-xs rounded-full">Verified</span>
|
||||
)}
|
||||
{!u.is_active && (
|
||||
<span className="px-2 py-0.5 bg-danger-muted text-danger text-ui-xs rounded-full">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx(
|
||||
"px-2 py-1 text-ui-xs font-medium rounded-lg",
|
||||
u.subscription.tier === 'tycoon' ? "bg-warning/20 text-warning" :
|
||||
u.subscription.tier === 'trader' ? "bg-accent/20 text-accent" :
|
||||
"bg-background-tertiary text-foreground-muted"
|
||||
)}>
|
||||
{u.subscription.tier_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
<span className="text-body-sm text-foreground-muted">{u.domain_count}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<select
|
||||
value={u.subscription.tier}
|
||||
onChange={(e) => handleUpgradeUser(u.id, e.target.value)}
|
||||
className="px-2 py-1 bg-background-secondary border border-border rounded-lg text-ui-xs text-foreground"
|
||||
>
|
||||
<option value="scout">Scout</option>
|
||||
<option value="trader">Trader</option>
|
||||
<option value="tycoon">Tycoon</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(u.id, u.is_admin)}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-lg transition-colors",
|
||||
u.is_admin
|
||||
? "bg-accent/20 text-accent hover:bg-accent/30"
|
||||
: "bg-background-tertiary text-foreground-subtle hover:text-foreground"
|
||||
)}
|
||||
title={u.is_admin ? 'Remove admin' : 'Make admin'}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
Showing {users.length} of {usersTotal} users
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletter Tab */}
|
||||
{activeTab === 'newsletter' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-body-sm text-foreground-muted">
|
||||
{newsletterTotal} total subscribers
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const data = await api.exportNewsletterEmails()
|
||||
const blob = new Blob([data.emails.join('\n')], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'newsletter-emails.txt'
|
||||
a.click()
|
||||
} catch (err) {
|
||||
setError('Export failed')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-accent text-background rounded-lg text-ui-sm font-medium hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Export Emails
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-background-secondary/50 border-b border-border">
|
||||
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Email</th>
|
||||
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Status</th>
|
||||
<th className="text-left px-4 py-3 text-ui-sm font-medium text-foreground-muted">Subscribed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{newsletter.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-background-secondary/30">
|
||||
<td className="px-4 py-3 text-body-sm text-foreground">{s.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx(
|
||||
"px-2 py-0.5 text-ui-xs rounded-full",
|
||||
s.is_active ? "bg-accent-muted text-accent" : "bg-danger-muted text-danger"
|
||||
)}>
|
||||
{s.is_active ? 'Active' : 'Unsubscribed'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-body-sm text-foreground-muted">
|
||||
{new Date(s.subscribed_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLD Tab */}
|
||||
{activeTab === 'tld' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-4">TLD Price Data</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-muted">Unique TLDs</span>
|
||||
<span className="font-medium text-foreground">{stats?.tld_data.unique_tlds || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-muted">Price Records</span>
|
||||
<span className="font-medium text-foreground">{stats?.tld_data.price_records?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-4">Scrape TLD Prices</h3>
|
||||
<p className="text-body-sm text-foreground-muted mb-4">
|
||||
Manually trigger a TLD price scrape from all sources.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleTriggerScrape}
|
||||
disabled={scraping}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-background rounded-lg font-medium hover:bg-accent-hover disabled:opacity-50 transition-all"
|
||||
>
|
||||
{scraping ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
{scraping ? 'Scraping...' : 'Start Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Tab */}
|
||||
{activeTab === 'system' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-4">System Status</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-foreground-muted">Database</span>
|
||||
<span className="flex items-center gap-2 text-accent">
|
||||
<span className="w-2 h-2 bg-accent rounded-full"></span>
|
||||
Healthy
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-foreground-muted">Email (Zoho SMTP)</span>
|
||||
<span className="flex items-center gap-2 text-accent">
|
||||
<span className="w-2 h-2 bg-accent rounded-full"></span>
|
||||
Configured
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-foreground-muted">Stripe Payments</span>
|
||||
<span className="flex items-center gap-2 text-foreground-muted">
|
||||
<span className="w-2 h-2 bg-foreground-subtle rounded-full"></span>
|
||||
Check .env
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-4">Environment</h3>
|
||||
<div className="font-mono text-body-sm text-foreground-muted space-y-1">
|
||||
<p>SMTP_HOST: smtp.zoho.eu</p>
|
||||
<p>SMTP_PORT: 465</p>
|
||||
<p>SMTP_USE_SSL: true</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
subtitle: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-ui-sm">{title}</span>
|
||||
</div>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-foreground mb-1">{value.toLocaleString()}</p>
|
||||
<p className="text-ui-sm text-foreground-subtle">{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -13,43 +13,46 @@ export function Header() {
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border-subtle">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 sm:h-20 flex items-center justify-between">
|
||||
{/* Left side: Logo + Nav Links */}
|
||||
<div className="flex items-center gap-6 sm:gap-8">
|
||||
{/* Logo - Playfair Display (same as titles) */}
|
||||
<div className="flex items-center gap-6 sm:gap-8 h-full">
|
||||
{/* Logo - Playfair Display font */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center hover:opacity-80 transition-opacity duration-300"
|
||||
className="flex items-center h-full hover:opacity-80 transition-opacity duration-300"
|
||||
>
|
||||
<span className="font-display text-xl sm:text-2xl font-bold tracking-[-0.02em] text-foreground leading-none">
|
||||
<span
|
||||
className="text-[1.375rem] sm:text-[1.625rem] font-bold tracking-[-0.02em] text-foreground"
|
||||
style={{ fontFamily: 'var(--font-display), Playfair Display, Georgia, serif' }}
|
||||
>
|
||||
pounce
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Left Nav Links (Desktop) - vertically centered */}
|
||||
<nav className="hidden sm:flex items-center gap-1">
|
||||
<nav className="hidden sm:flex items-center h-full gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center h-9 px-3 text-ui text-foreground-muted hover:text-foreground
|
||||
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Domain
|
||||
</Link>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="flex items-center h-9 px-3 text-ui text-foreground-muted hover:text-foreground
|
||||
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
TLD
|
||||
</Link>
|
||||
<Link
|
||||
href="/auctions"
|
||||
className="flex items-center h-9 px-3 text-ui text-foreground-muted hover:text-foreground
|
||||
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Auctions
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="flex items-center h-9 px-3 text-ui text-foreground-muted hover:text-foreground
|
||||
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Plans
|
||||
@ -58,12 +61,12 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{/* Right side: Auth Links - vertically centered */}
|
||||
<nav className="hidden sm:flex items-center gap-1">
|
||||
<nav className="hidden sm:flex items-center h-full gap-1">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-2 h-9 px-4 text-ui text-foreground-muted
|
||||
className="flex items-center gap-2 h-9 px-4 text-[0.8125rem] text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-lg
|
||||
transition-all duration-300"
|
||||
>
|
||||
@ -79,8 +82,8 @@ export function Header() {
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-border">
|
||||
<span className="text-ui text-foreground-subtle hidden md:inline">
|
||||
<div className="flex items-center h-full gap-3 ml-2 pl-4 border-l border-border">
|
||||
<span className="text-[0.8125rem] text-foreground-subtle hidden md:flex items-center">
|
||||
{user?.email}
|
||||
</span>
|
||||
|
||||
@ -98,14 +101,14 @@ export function Header() {
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center h-9 px-4 text-ui text-foreground-muted hover:text-foreground
|
||||
className="flex items-center h-9 px-4 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center h-9 ml-2 px-5 text-ui bg-foreground text-background rounded-lg
|
||||
className="flex items-center h-9 ml-2 px-5 text-[0.8125rem] bg-foreground text-background rounded-lg
|
||||
font-medium hover:bg-foreground/90 transition-all duration-300"
|
||||
>
|
||||
Get Started
|
||||
|
||||
@ -730,5 +730,72 @@ export interface PriceAlert {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const api = new ApiClient()
|
||||
// ============== Admin API Extension ==============
|
||||
|
||||
class AdminApiClient extends ApiClient {
|
||||
// Admin Stats
|
||||
async getAdminStats() {
|
||||
return this.request<any>('/admin/stats')
|
||||
}
|
||||
|
||||
// Admin Users
|
||||
async getAdminUsers(limit: number = 50, offset: number = 0, search?: string) {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||
if (search) params.append('search', search)
|
||||
return this.request<any>(`/admin/users?${params}`)
|
||||
}
|
||||
|
||||
async getAdminUser(userId: number) {
|
||||
return this.request<any>(`/admin/users/${userId}`)
|
||||
}
|
||||
|
||||
async updateAdminUser(userId: number, data: { name?: string; is_active?: boolean; is_verified?: boolean; is_admin?: boolean }) {
|
||||
return this.request<any>(`/admin/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteAdminUser(userId: number) {
|
||||
return this.request<any>(`/admin/users/${userId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async upgradeUser(userId: number, tier: string) {
|
||||
return this.request<any>(`/admin/users/${userId}/upgrade?tier=${tier}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
// Newsletter
|
||||
async getAdminNewsletter(limit: number = 100, offset: number = 0, activeOnly: boolean = true) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
active_only: String(activeOnly),
|
||||
})
|
||||
return this.request<any>(`/admin/newsletter?${params}`)
|
||||
}
|
||||
|
||||
async exportNewsletterEmails() {
|
||||
return this.request<{ emails: string[]; count: number }>('/admin/newsletter/export')
|
||||
}
|
||||
|
||||
// TLD Scraping
|
||||
async triggerTldScrape() {
|
||||
return this.request<any>('/admin/scrape-tld-prices', { method: 'POST' })
|
||||
}
|
||||
|
||||
async getTldPriceStats() {
|
||||
return this.request<any>('/admin/tld-prices/stats')
|
||||
}
|
||||
|
||||
// System
|
||||
async getSystemHealth() {
|
||||
return this.request<any>('/admin/system/health')
|
||||
}
|
||||
|
||||
async makeUserAdmin(email: string) {
|
||||
return this.request<any>(`/admin/system/make-admin?email=${encodeURIComponent(email)}`, { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new AdminApiClient()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user