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
|
Admin API endpoints for pounce administration.
|
||||||
from pydantic import BaseModel, EmailStr
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
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.user import User
|
||||||
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
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()
|
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")
|
@router.post("/scrape-tld-prices")
|
||||||
async def trigger_tld_scrape(background_tasks: BackgroundTasks, db: Database):
|
async def trigger_tld_scrape(
|
||||||
"""
|
db: Database,
|
||||||
Manually trigger a TLD price scrape.
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
This runs in the background and returns immediately.
|
"""Manually trigger a TLD price scrape."""
|
||||||
Check logs for scrape results.
|
|
||||||
|
|
||||||
NOTE: In production, this should require admin authentication!
|
|
||||||
"""
|
|
||||||
from app.services.tld_scraper.aggregator import tld_aggregator
|
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)
|
result = await tld_aggregator.run_scrape(db)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -43,34 +483,34 @@ async def trigger_tld_scrape(background_tasks: BackgroundTasks, db: Database):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/tld-prices/stats")
|
@router.get("/tld-prices/stats")
|
||||||
async def get_tld_price_stats(db: Database):
|
async def get_tld_price_stats(
|
||||||
"""Get statistics about stored TLD price data."""
|
db: Database,
|
||||||
from sqlalchemy import func
|
admin: User = Depends(require_admin),
|
||||||
from app.models.tld_price import TLDPrice
|
):
|
||||||
|
"""Get TLD price data statistics."""
|
||||||
# Total records
|
# Total records
|
||||||
total_result = await db.execute(select(func.count(TLDPrice.id)))
|
total = await db.execute(select(func.count(TLDPrice.id)))
|
||||||
total_records = total_result.scalar()
|
total_records = total.scalar()
|
||||||
|
|
||||||
# Unique TLDs
|
# Unique TLDs
|
||||||
tlds_result = await db.execute(select(func.count(func.distinct(TLDPrice.tld))))
|
tlds = await db.execute(select(func.count(func.distinct(TLDPrice.tld))))
|
||||||
unique_tlds = tlds_result.scalar()
|
unique_tlds = tlds.scalar()
|
||||||
|
|
||||||
# Unique registrars
|
# Unique registrars
|
||||||
registrars_result = await db.execute(select(func.count(func.distinct(TLDPrice.registrar))))
|
registrars = await db.execute(select(func.count(func.distinct(TLDPrice.registrar))))
|
||||||
unique_registrars = registrars_result.scalar()
|
unique_registrars = registrars.scalar()
|
||||||
|
|
||||||
# Latest record
|
# Latest record
|
||||||
latest_result = await db.execute(
|
latest = await db.execute(
|
||||||
select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.desc()).limit(1)
|
select(TLDPrice.recorded_at).order_by(desc(TLDPrice.recorded_at)).limit(1)
|
||||||
)
|
)
|
||||||
latest_record = latest_result.scalar()
|
latest_record = latest.scalar()
|
||||||
|
|
||||||
# Oldest record
|
# Oldest record
|
||||||
oldest_result = await db.execute(
|
oldest = await db.execute(
|
||||||
select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.asc()).limit(1)
|
select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.asc()).limit(1)
|
||||||
)
|
)
|
||||||
oldest_record = oldest_result.scalar()
|
oldest_record = oldest.scalar()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_records": total_records,
|
"total_records": total_records,
|
||||||
@ -82,108 +522,55 @@ async def get_tld_price_stats(db: Database):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class UpgradeUserRequest(BaseModel):
|
# ============== System ==============
|
||||||
"""Request schema for upgrading a user."""
|
|
||||||
email: EmailStr
|
|
||||||
tier: str # starter, professional, enterprise
|
|
||||||
|
|
||||||
|
@router.get("/system/health")
|
||||||
@router.post("/upgrade-user")
|
async def system_health(
|
||||||
async def upgrade_user(request: UpgradeUserRequest, db: Database):
|
db: Database,
|
||||||
"""
|
admin: User = Depends(require_admin),
|
||||||
Upgrade a user's subscription tier.
|
):
|
||||||
|
"""Check system health."""
|
||||||
NOTE: In production, this should require admin authentication!
|
# Test database
|
||||||
"""
|
|
||||||
# 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
|
|
||||||
try:
|
try:
|
||||||
new_tier = SubscriptionTier(request.tier)
|
await db.execute(select(1))
|
||||||
except ValueError:
|
db_status = "healthy"
|
||||||
raise HTTPException(
|
except Exception as e:
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
db_status = f"error: {str(e)}"
|
||||||
detail=f"Invalid tier: {request.tier}. Valid options: starter, professional, enterprise"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find or create subscription
|
# Check email config
|
||||||
result = await db.execute(
|
from app.services.email_service import EmailService
|
||||||
select(Subscription).where(Subscription.user_id == user.id)
|
email_configured = EmailService.is_configured()
|
||||||
)
|
|
||||||
subscription = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not subscription:
|
# Check Stripe config
|
||||||
# Create new subscription
|
import os
|
||||||
subscription = Subscription(
|
stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY"))
|
||||||
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]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"User {request.email} upgraded to {config['name']}",
|
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||||
"user_id": user.id,
|
"database": db_status,
|
||||||
"tier": new_tier.value,
|
"email_configured": email_configured,
|
||||||
"tier_name": config["name"],
|
"stripe_configured": stripe_configured,
|
||||||
"domain_limit": config["domain_limit"],
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
"features": config["features"],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.post("/system/make-admin")
|
||||||
async def list_users(db: Database, limit: int = 50, offset: int = 0):
|
async def make_user_admin(
|
||||||
|
email: str,
|
||||||
|
db: Database,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
List all users with their subscriptions.
|
Make a user an admin.
|
||||||
|
NOTE: This endpoint has NO authentication for initial setup!
|
||||||
NOTE: In production, this should require admin authentication!
|
In production, disable after first admin is created.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
select(User).offset(offset).limit(limit)
|
user = result.scalar_one_or_none()
|
||||||
)
|
|
||||||
users = result.scalars().all()
|
|
||||||
|
|
||||||
user_list = []
|
if not user:
|
||||||
for user in users:
|
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()
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
# Status
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# Password Reset
|
# Password Reset
|
||||||
password_reset_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
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">
|
<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">
|
<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 */}
|
{/* Left side: Logo + Nav Links */}
|
||||||
<div className="flex items-center gap-6 sm:gap-8">
|
<div className="flex items-center gap-6 sm:gap-8 h-full">
|
||||||
{/* Logo - Playfair Display (same as titles) */}
|
{/* Logo - Playfair Display font */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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
|
pounce
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Left Nav Links (Desktop) - vertically centered */}
|
{/* 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
|
<Link
|
||||||
href="/"
|
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"
|
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
Domain
|
Domain
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/tld-pricing"
|
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"
|
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
TLD
|
TLD
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/auctions"
|
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"
|
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
Auctions
|
Auctions
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
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"
|
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
Plans
|
Plans
|
||||||
@ -58,12 +61,12 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: Auth Links - vertically centered */}
|
{/* 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 ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
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
|
hover:text-foreground hover:bg-background-secondary rounded-lg
|
||||||
transition-all duration-300"
|
transition-all duration-300"
|
||||||
>
|
>
|
||||||
@ -79,8 +82,8 @@ export function Header() {
|
|||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-border">
|
<div className="flex items-center h-full gap-3 ml-2 pl-4 border-l border-border">
|
||||||
<span className="text-ui text-foreground-subtle hidden md:inline">
|
<span className="text-[0.8125rem] text-foreground-subtle hidden md:flex items-center">
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@ -98,14 +101,14 @@ export function Header() {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
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"
|
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
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"
|
font-medium hover:bg-foreground/90 transition-all duration-300"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
|
|||||||
@ -730,5 +730,72 @@ export interface PriceAlert {
|
|||||||
created_at: string
|
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