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

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:
yves.gugger
2025-12-08 16:05:53 +01:00
parent 1efc7fe28e
commit b5a0d17689
5 changed files with 1236 additions and 143 deletions

View File

@ -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"}

View File

@ -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)

View 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>
)
}

View File

@ -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

View File

@ -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()