Backend: - User authentication with JWT tokens (auth_service.py) - Subscription plans: Free, Starter (), Pro (), Business (), Enterprise - Stripe integration for payments (payment_service.py) - Usage tracking and quotas - Credit packages for pay-per-use - Plan-based provider restrictions Frontend: - Landing page with hero, features, pricing preview (landing-sections.tsx) - Pricing page with all plans and credit packages (/pricing) - User dashboard with usage stats (/dashboard) - Login/Register pages with validation (/auth/login, /auth/register) - Ollama self-hosting setup guide (/ollama-setup) - Updated sidebar with user section and plan badge Monetization strategy: - Freemium: 3 docs/day, Ollama only - Starter: 50 docs/month, Google Translate - Pro: 200 docs/month, all providers, API access - Business: 1000 docs/month, team management - Enterprise: Custom pricing, SLA Self-hosted option: - Free unlimited usage with own Ollama server - Complete privacy (data never leaves machine) - Step-by-step setup guide included
282 lines
8.8 KiB
Python
282 lines
8.8 KiB
Python
"""
|
|
Authentication and User API routes
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends, Header, Request
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from pydantic import BaseModel, EmailStr
|
|
from typing import Optional, Dict, Any
|
|
|
|
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
|
|
from services.auth_service import (
|
|
create_user, authenticate_user, get_user_by_id,
|
|
create_access_token, create_refresh_token, verify_token,
|
|
check_usage_limits, update_user
|
|
)
|
|
from services.payment_service import (
|
|
create_checkout_session, create_credits_checkout,
|
|
cancel_subscription, get_billing_portal_url,
|
|
handle_webhook, is_stripe_configured
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
# Request/Response models
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
user: UserResponse
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class CheckoutRequest(BaseModel):
|
|
plan: PlanType
|
|
billing_period: str = "monthly"
|
|
|
|
|
|
class CreditsCheckoutRequest(BaseModel):
|
|
package_index: int
|
|
|
|
|
|
# Dependency to get current user
|
|
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
|
|
if not credentials:
|
|
return None
|
|
|
|
payload = verify_token(credentials.credentials)
|
|
if not payload:
|
|
return None
|
|
|
|
user = get_user_by_id(payload.get("sub"))
|
|
return user
|
|
|
|
|
|
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
payload = verify_token(credentials.credentials)
|
|
if not payload:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
user = get_user_by_id(payload.get("sub"))
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
|
|
return user
|
|
|
|
|
|
def user_to_response(user) -> UserResponse:
|
|
"""Convert User to UserResponse with plan limits"""
|
|
plan_limits = PLANS[user.plan]
|
|
return UserResponse(
|
|
id=user.id,
|
|
email=user.email,
|
|
name=user.name,
|
|
avatar_url=user.avatar_url,
|
|
plan=user.plan,
|
|
subscription_status=user.subscription_status,
|
|
docs_translated_this_month=user.docs_translated_this_month,
|
|
pages_translated_this_month=user.pages_translated_this_month,
|
|
api_calls_this_month=user.api_calls_this_month,
|
|
extra_credits=user.extra_credits,
|
|
created_at=user.created_at,
|
|
plan_limits={
|
|
"docs_per_month": plan_limits["docs_per_month"],
|
|
"max_pages_per_doc": plan_limits["max_pages_per_doc"],
|
|
"max_file_size_mb": plan_limits["max_file_size_mb"],
|
|
"providers": plan_limits["providers"],
|
|
"features": plan_limits["features"],
|
|
"api_access": plan_limits.get("api_access", False),
|
|
}
|
|
)
|
|
|
|
|
|
# Auth endpoints
|
|
@router.post("/register", response_model=TokenResponse)
|
|
async def register(user_create: UserCreate):
|
|
"""Register a new user"""
|
|
try:
|
|
user = create_user(user_create)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
access_token = create_access_token(user.id)
|
|
refresh_token = create_refresh_token(user.id)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user=user_to_response(user)
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(credentials: UserLogin):
|
|
"""Login with email and password"""
|
|
user = authenticate_user(credentials.email, credentials.password)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Invalid email or password")
|
|
|
|
access_token = create_access_token(user.id)
|
|
refresh_token = create_refresh_token(user.id)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user=user_to_response(user)
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh_tokens(request: RefreshRequest):
|
|
"""Refresh access token"""
|
|
payload = verify_token(request.refresh_token)
|
|
if not payload or payload.get("type") != "refresh":
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
|
|
user = get_user_by_id(payload.get("sub"))
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
|
|
access_token = create_access_token(user.id)
|
|
refresh_token = create_refresh_token(user.id)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user=user_to_response(user)
|
|
)
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_me(user=Depends(require_user)):
|
|
"""Get current user info"""
|
|
return user_to_response(user)
|
|
|
|
|
|
@router.get("/usage")
|
|
async def get_usage(user=Depends(require_user)):
|
|
"""Get current usage and limits"""
|
|
return check_usage_limits(user)
|
|
|
|
|
|
@router.put("/settings")
|
|
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
|
|
"""Update user settings"""
|
|
allowed_fields = [
|
|
"default_source_lang", "default_target_lang", "default_provider",
|
|
"ollama_endpoint", "ollama_model", "name"
|
|
]
|
|
|
|
updates = {k: v for k, v in settings.items() if k in allowed_fields}
|
|
updated_user = update_user(user.id, updates)
|
|
|
|
if not updated_user:
|
|
raise HTTPException(status_code=400, detail="Failed to update settings")
|
|
|
|
return user_to_response(updated_user)
|
|
|
|
|
|
# Plans endpoint (public)
|
|
@router.get("/plans")
|
|
async def get_plans():
|
|
"""Get all available plans"""
|
|
plans = []
|
|
for plan_type, config in PLANS.items():
|
|
plans.append({
|
|
"id": plan_type.value,
|
|
"name": config["name"],
|
|
"price_monthly": config["price_monthly"],
|
|
"price_yearly": config["price_yearly"],
|
|
"features": config["features"],
|
|
"docs_per_month": config["docs_per_month"],
|
|
"max_pages_per_doc": config["max_pages_per_doc"],
|
|
"max_file_size_mb": config["max_file_size_mb"],
|
|
"providers": config["providers"],
|
|
"api_access": config.get("api_access", False),
|
|
"popular": plan_type == PlanType.PRO,
|
|
})
|
|
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
|
|
|
|
|
|
# Payment endpoints
|
|
@router.post("/checkout/subscription")
|
|
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
|
|
"""Create Stripe checkout session for subscription"""
|
|
if not is_stripe_configured():
|
|
# Demo mode - just upgrade the user
|
|
update_user(user.id, {"plan": request.plan.value})
|
|
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
|
|
|
|
result = await create_checkout_session(
|
|
user.id,
|
|
request.plan,
|
|
request.billing_period
|
|
)
|
|
|
|
if "error" in result:
|
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/checkout/credits")
|
|
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
|
|
"""Create Stripe checkout session for credits"""
|
|
if not is_stripe_configured():
|
|
# Demo mode - add credits directly
|
|
from services.auth_service import add_credits
|
|
credits = CREDIT_PACKAGES[request.package_index]["credits"]
|
|
add_credits(user.id, credits)
|
|
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
|
|
|
|
result = await create_credits_checkout(user.id, request.package_index)
|
|
|
|
if "error" in result:
|
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/subscription/cancel")
|
|
async def cancel_user_subscription(user=Depends(require_user)):
|
|
"""Cancel subscription"""
|
|
result = await cancel_subscription(user.id)
|
|
|
|
if "error" in result:
|
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/billing-portal")
|
|
async def get_billing_portal(user=Depends(require_user)):
|
|
"""Get Stripe billing portal URL"""
|
|
url = await get_billing_portal_url(user.id)
|
|
|
|
if not url:
|
|
raise HTTPException(status_code=400, detail="Billing portal not available")
|
|
|
|
return {"url": url}
|
|
|
|
|
|
# Stripe webhook
|
|
@router.post("/webhook/stripe")
|
|
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
|
|
"""Handle Stripe webhooks"""
|
|
payload = await request.body()
|
|
|
|
result = await handle_webhook(payload, stripe_signature or "")
|
|
|
|
if "error" in result:
|
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
|
|
return result
|