feat: Add missing features - Stripe payments, password reset, rate limiting, contact form, newsletter
Backend: - Add Stripe API endpoints (checkout, portal, webhook) in subscription.py - Add password reset (forgot-password, reset-password) in auth.py - Add email verification endpoints - Add rate limiting with slowapi - Add contact form and newsletter API (contact.py) - Add webhook endpoint for Stripe (webhooks.py) - Add NewsletterSubscriber model - Extend User model with password reset and email verification tokens - Extend email_service with new templates (password reset, verification, contact, newsletter) - Update env.example with all new environment variables Frontend: - Add /forgot-password page - Add /reset-password page with token handling - Add /verify-email page with auto-verification - Add forgot password link to login page - Connect contact form to API - Add API methods for all new endpoints Documentation: - Update README with new API endpoints - Update environment variables documentation - Update pages overview
This commit is contained in:
59
README.md
59
README.md
@ -40,6 +40,13 @@ A professional full-stack application for monitoring domain name availability wi
|
||||
- **Authentication** — Secure JWT-based auth with subscription tiers
|
||||
- **Dashboard** — Personal watchlist with status indicators and actions
|
||||
|
||||
### Security Features (v1.1)
|
||||
- **Password Reset** — Secure token-based password recovery via email
|
||||
- **Email Verification** — Optional email confirmation for new accounts
|
||||
- **Rate Limiting** — Protection against brute-force attacks (slowapi)
|
||||
- **Stripe Payments** — Secure subscription payments with Stripe Checkout
|
||||
- **Contact Form** — With email confirmation and spam protection
|
||||
|
||||
### TLD Detail Page (Professional)
|
||||
- **Price Hero** — Instant view of cheapest price with direct registration link
|
||||
- **Price Alert System** — Subscribe to email notifications for price changes
|
||||
@ -161,10 +168,20 @@ pounce/
|
||||
| `/` | Homepage with domain checker, features, pricing preview | No |
|
||||
| `/login` | User login | No |
|
||||
| `/register` | User registration | No |
|
||||
| `/forgot-password` | Request password reset | No |
|
||||
| `/reset-password` | Reset password with token | No |
|
||||
| `/verify-email` | Verify email with token | No |
|
||||
| `/dashboard` | Personal domain watchlist | Yes |
|
||||
| `/pricing` | Subscription plans with FAQ | No |
|
||||
| `/pricing` | Subscription plans with Stripe checkout | No |
|
||||
| `/tld-pricing` | TLD price overview with trends | No* |
|
||||
| `/tld-pricing/[tld]` | TLD detail with registrar comparison | Yes |
|
||||
| `/auctions` | Smart Pounce auction aggregator | No* |
|
||||
| `/contact` | Contact form | No |
|
||||
| `/about` | About us | No |
|
||||
| `/blog` | Blog & Newsletter signup | No |
|
||||
| `/privacy` | Privacy policy | No |
|
||||
| `/terms` | Terms of service | No |
|
||||
| `/imprint` | Legal imprint | No |
|
||||
|
||||
*Unauthenticated users see limited data with shimmer effects
|
||||
|
||||
@ -244,9 +261,14 @@ npm run dev
|
||||
### Authentication
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/auth/register` | Register new user |
|
||||
| POST | `/api/v1/auth/register` | Register new user (sends verification email) |
|
||||
| POST | `/api/v1/auth/login` | Login (returns JWT) |
|
||||
| GET | `/api/v1/auth/me` | Get current user |
|
||||
| PUT | `/api/v1/auth/me` | Update current user |
|
||||
| POST | `/api/v1/auth/forgot-password` | Request password reset |
|
||||
| POST | `/api/v1/auth/reset-password` | Reset password with token |
|
||||
| POST | `/api/v1/auth/verify-email` | Verify email with token |
|
||||
| POST | `/api/v1/auth/resend-verification` | Resend verification email |
|
||||
|
||||
### Domain Check
|
||||
| Method | Endpoint | Description |
|
||||
@ -293,11 +315,28 @@ This ensures identical prices on:
|
||||
- Compare page (`/{tld}/compare`)
|
||||
- Trending cards (`/trending`)
|
||||
|
||||
### Subscription
|
||||
### Subscription & Payments
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/subscription` | Get current subscription |
|
||||
| POST | `/api/v1/subscription/upgrade` | Upgrade plan |
|
||||
| GET | `/api/v1/subscription/tiers` | Get available tiers |
|
||||
| GET | `/api/v1/subscription/features` | Get current features |
|
||||
| POST | `/api/v1/subscription/checkout` | Create Stripe checkout session |
|
||||
| POST | `/api/v1/subscription/portal` | Create Stripe customer portal |
|
||||
| POST | `/api/v1/subscription/cancel` | Cancel subscription |
|
||||
|
||||
### Contact & Newsletter
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/contact` | Submit contact form |
|
||||
| POST | `/api/v1/contact/newsletter/subscribe` | Subscribe to newsletter |
|
||||
| POST | `/api/v1/contact/newsletter/unsubscribe` | Unsubscribe from newsletter |
|
||||
| GET | `/api/v1/contact/newsletter/status` | Check subscription status |
|
||||
|
||||
### Webhooks
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/webhooks/stripe` | Stripe webhook handler |
|
||||
|
||||
### Smart Pounce (Auctions)
|
||||
| Method | Endpoint | Description |
|
||||
@ -342,13 +381,23 @@ This ensures identical prices on:
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///./domainwatch.db` |
|
||||
| `SECRET_KEY` | JWT signing key (min 32 chars) | **Required** |
|
||||
| `CORS_ORIGINS` | Allowed origins (comma-separated) | `http://localhost:3000` |
|
||||
| `ALLOWED_ORIGINS` | Allowed origins (comma-separated) | `http://localhost:3000` |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT token lifetime | `10080` (7 days) |
|
||||
| `SITE_URL` | Frontend URL (for email links) | `http://localhost:3000` |
|
||||
| `REQUIRE_EMAIL_VERIFICATION` | Require email verification | `false` |
|
||||
| `SCHEDULER_CHECK_INTERVAL_HOURS` | Domain check interval | `24` |
|
||||
| **SMTP Settings** | | |
|
||||
| `SMTP_HOST` | Email server host | Optional |
|
||||
| `SMTP_PORT` | Email server port | `587` |
|
||||
| `SMTP_USER` | Email username | Optional |
|
||||
| `SMTP_PASSWORD` | Email password | Optional |
|
||||
| `SMTP_FROM_EMAIL` | Sender email | `noreply@pounce.ch` |
|
||||
| `CONTACT_EMAIL` | Contact form recipient | `support@pounce.ch` |
|
||||
| **Stripe Settings** | | |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API secret key | Optional |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | Optional |
|
||||
| `STRIPE_PRICE_TRADER` | Trader plan Price ID | Optional |
|
||||
| `STRIPE_PRICE_TYCOON` | Tycoon plan Price ID | Optional |
|
||||
|
||||
### Frontend (.env.local)
|
||||
|
||||
|
||||
@ -9,9 +9,12 @@ from app.api.admin import router as admin_router
|
||||
from app.api.tld_prices import router as tld_prices_router
|
||||
from app.api.portfolio import router as portfolio_router
|
||||
from app.api.auctions import router as auctions_router
|
||||
from app.api.webhooks import router as webhooks_router
|
||||
from app.api.contact import router as contact_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Core API endpoints
|
||||
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
api_router.include_router(check_router, prefix="/check", tags=["Domain Check"])
|
||||
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
|
||||
@ -19,5 +22,12 @@ api_router.include_router(subscription_router, prefix="/subscription", tags=["Su
|
||||
api_router.include_router(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"])
|
||||
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
|
||||
api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"])
|
||||
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
# Support & Communication
|
||||
api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"])
|
||||
|
||||
# Webhooks (external service callbacks)
|
||||
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
|
||||
|
||||
# Admin endpoints
|
||||
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
@ -1,20 +1,88 @@
|
||||
"""Authentication API endpoints."""
|
||||
from datetime import timedelta
|
||||
"""
|
||||
Authentication API endpoints.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
Endpoints:
|
||||
- POST /auth/register - Register new user
|
||||
- POST /auth/login - Login and get JWT token
|
||||
- GET /auth/me - Get current user info
|
||||
- PUT /auth/me - Update current user
|
||||
- POST /auth/forgot-password - Request password reset
|
||||
- POST /auth/reset-password - Reset password with token
|
||||
- POST /auth/verify-email - Verify email address
|
||||
- POST /auth/resend-verification - Resend verification email
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.deps import Database, CurrentUser
|
||||
from app.config import get_settings
|
||||
from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token
|
||||
from app.services.auth import AuthService
|
||||
from app.services.email_service import email_service
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Rate limiter for auth endpoints
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
"""Request password reset."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Reset password with token."""
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
"""Verify email with token."""
|
||||
token: str
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Simple message response."""
|
||||
message: str
|
||||
success: bool = True
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""Update user profile."""
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
# ============== Endpoints ==============
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(user_data: UserCreate, db: Database):
|
||||
"""Register a new user."""
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
db: Database,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
- Creates user account
|
||||
- Sends verification email (if SMTP configured)
|
||||
- Returns user info (without password)
|
||||
"""
|
||||
# Check if user exists
|
||||
existing_user = await AuthService.get_user_by_email(db, user_data.email)
|
||||
if existing_user:
|
||||
@ -31,12 +99,35 @@ async def register(user_data: UserCreate, db: Database):
|
||||
name=user_data.name,
|
||||
)
|
||||
|
||||
# Generate verification token
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = datetime.utcnow() + timedelta(hours=24)
|
||||
await db.commit()
|
||||
|
||||
# Send verification email in background
|
||||
if email_service.is_configured:
|
||||
site_url = os.getenv("SITE_URL", "http://localhost:3000")
|
||||
verify_url = f"{site_url}/verify-email?token={verification_token}"
|
||||
|
||||
background_tasks.add_task(
|
||||
email_service.send_email_verification,
|
||||
to_email=user.email,
|
||||
user_name=user.name or "there",
|
||||
verification_url=verify_url,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(user_data: UserLogin, db: Database):
|
||||
"""Authenticate user and return JWT token."""
|
||||
"""
|
||||
Authenticate user and return JWT token.
|
||||
|
||||
Note: Email verification is currently not enforced.
|
||||
Set REQUIRE_EMAIL_VERIFICATION=true to enforce.
|
||||
"""
|
||||
user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
|
||||
|
||||
if not user:
|
||||
@ -46,6 +137,14 @@ async def login(user_data: UserLogin, db: Database):
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Optional: Check email verification
|
||||
require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"
|
||||
if require_verification and not user.is_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Please verify your email address before logging in",
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
access_token = AuthService.create_access_token(
|
||||
data={"sub": str(user.id), "email": user.email},
|
||||
@ -67,16 +166,190 @@ async def get_current_user_info(current_user: CurrentUser):
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_current_user(
|
||||
update_data: UpdateUserRequest,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
name: str = None,
|
||||
):
|
||||
"""Update current user information."""
|
||||
if name is not None:
|
||||
current_user.name = name
|
||||
if update_data.name is not None:
|
||||
current_user.name = update_data.name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=MessageResponse)
|
||||
async def forgot_password(
|
||||
request: ForgotPasswordRequest,
|
||||
db: Database,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""
|
||||
Request password reset email.
|
||||
|
||||
- Always returns success (to prevent email enumeration)
|
||||
- If email exists, sends reset link
|
||||
- Reset token expires in 1 hour
|
||||
"""
|
||||
# Always return success (security: don't reveal if email exists)
|
||||
success_message = "If an account with this email exists, a password reset link has been sent."
|
||||
|
||||
# Look up user
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == request.email.lower())
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# Return success anyway (security)
|
||||
return MessageResponse(message=success_message)
|
||||
|
||||
# Generate reset token
|
||||
reset_token = secrets.token_urlsafe(32)
|
||||
user.password_reset_token = reset_token
|
||||
user.password_reset_expires = datetime.utcnow() + timedelta(hours=1)
|
||||
await db.commit()
|
||||
|
||||
# Send reset email in background
|
||||
if email_service.is_configured:
|
||||
site_url = os.getenv("SITE_URL", "http://localhost:3000")
|
||||
reset_url = f"{site_url}/reset-password?token={reset_token}"
|
||||
|
||||
background_tasks.add_task(
|
||||
email_service.send_password_reset,
|
||||
to_email=user.email,
|
||||
user_name=user.name or "there",
|
||||
reset_url=reset_url,
|
||||
)
|
||||
logger.info(f"Password reset email queued for {user.email}")
|
||||
else:
|
||||
logger.warning(f"SMTP not configured, cannot send reset email for {user.email}")
|
||||
|
||||
return MessageResponse(message=success_message)
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
async def reset_password(
|
||||
request: ResetPasswordRequest,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Reset password using token from email.
|
||||
|
||||
- Token must be valid and not expired
|
||||
- Password must be at least 8 characters
|
||||
- Invalidates token after use
|
||||
"""
|
||||
if len(request.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password must be at least 8 characters",
|
||||
)
|
||||
|
||||
# Find user with valid token
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.password_reset_token == request.token,
|
||||
User.password_reset_expires > datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired reset token",
|
||||
)
|
||||
|
||||
# Update password
|
||||
user.password_hash = AuthService.get_password_hash(request.new_password)
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires = None
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Password reset successful for user {user.id}")
|
||||
|
||||
return MessageResponse(message="Password has been reset successfully. You can now log in.")
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=MessageResponse)
|
||||
async def verify_email(
|
||||
request: VerifyEmailRequest,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Verify email address using token from email.
|
||||
|
||||
- Token must be valid and not expired
|
||||
- Marks user as verified
|
||||
"""
|
||||
# Find user with valid token
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.email_verification_token == request.token,
|
||||
User.email_verification_expires > datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired verification token",
|
||||
)
|
||||
|
||||
# Mark as verified
|
||||
user.is_verified = True
|
||||
user.email_verification_token = None
|
||||
user.email_verification_expires = None
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Email verified for user {user.id}")
|
||||
|
||||
return MessageResponse(message="Email verified successfully. You can now log in.")
|
||||
|
||||
|
||||
@router.post("/resend-verification", response_model=MessageResponse)
|
||||
async def resend_verification(
|
||||
request: ForgotPasswordRequest, # Reuse schema - just needs email
|
||||
db: Database,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""
|
||||
Resend verification email.
|
||||
|
||||
- Rate limited to prevent abuse
|
||||
- Always returns success (security)
|
||||
"""
|
||||
success_message = "If an unverified account with this email exists, a verification link has been sent."
|
||||
|
||||
# Look up user
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == request.email.lower())
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or user.is_verified:
|
||||
return MessageResponse(message=success_message)
|
||||
|
||||
# Generate new verification token
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = datetime.utcnow() + timedelta(hours=24)
|
||||
await db.commit()
|
||||
|
||||
# Send verification email
|
||||
if email_service.is_configured:
|
||||
site_url = os.getenv("SITE_URL", "http://localhost:3000")
|
||||
verify_url = f"{site_url}/verify-email?token={verification_token}"
|
||||
|
||||
background_tasks.add_task(
|
||||
email_service.send_email_verification,
|
||||
to_email=user.email,
|
||||
user_name=user.name or "there",
|
||||
verification_url=verify_url,
|
||||
)
|
||||
|
||||
return MessageResponse(message=success_message)
|
||||
|
||||
236
backend/app/api/contact.py
Normal file
236
backend/app/api/contact.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""
|
||||
Contact and Newsletter API endpoints.
|
||||
|
||||
Endpoints:
|
||||
- POST /contact - Submit contact form
|
||||
- POST /newsletter/subscribe - Subscribe to newsletter
|
||||
- POST /newsletter/unsubscribe - Unsubscribe from newsletter
|
||||
|
||||
Rate Limits:
|
||||
- Contact form: 5 requests per hour per IP
|
||||
- Newsletter: 10 requests per hour per IP
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy import select, delete
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.deps import Database
|
||||
from app.services.email_service import email_service
|
||||
from app.models.newsletter import NewsletterSubscriber
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Rate limiter for contact endpoints
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
class ContactRequest(BaseModel):
|
||||
"""Contact form submission."""
|
||||
name: str = Field(..., min_length=2, max_length=100)
|
||||
email: EmailStr
|
||||
subject: str = Field(..., min_length=5, max_length=200)
|
||||
message: str = Field(..., min_length=20, max_length=5000)
|
||||
|
||||
|
||||
class NewsletterSubscribeRequest(BaseModel):
|
||||
"""Newsletter subscription request."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class NewsletterUnsubscribeRequest(BaseModel):
|
||||
"""Newsletter unsubscription request."""
|
||||
email: EmailStr
|
||||
token: Optional[str] = None # For one-click unsubscribe
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Simple message response."""
|
||||
message: str
|
||||
success: bool = True
|
||||
|
||||
|
||||
# ============== Contact Endpoints ==============
|
||||
|
||||
@router.post("", response_model=MessageResponse)
|
||||
async def submit_contact_form(
|
||||
request: ContactRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""
|
||||
Submit contact form.
|
||||
|
||||
- Sends email to support team
|
||||
- Sends confirmation to user
|
||||
- Rate limited to prevent abuse
|
||||
"""
|
||||
try:
|
||||
# Send emails in background
|
||||
background_tasks.add_task(
|
||||
email_service.send_contact_form,
|
||||
name=request.name,
|
||||
email=request.email,
|
||||
subject=request.subject,
|
||||
message=request.message,
|
||||
)
|
||||
|
||||
logger.info(f"Contact form submitted: {request.email} - {request.subject}")
|
||||
|
||||
return MessageResponse(
|
||||
message="Thank you for your message! We'll get back to you soon.",
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process contact form: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to submit contact form. Please try again later.",
|
||||
)
|
||||
|
||||
|
||||
# ============== Newsletter Endpoints ==============
|
||||
|
||||
@router.post("/newsletter/subscribe", response_model=MessageResponse)
|
||||
async def subscribe_newsletter(
|
||||
request: NewsletterSubscribeRequest,
|
||||
db: Database,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""
|
||||
Subscribe to pounce newsletter.
|
||||
|
||||
- Stores email in database
|
||||
- Sends welcome email
|
||||
- Idempotent (subscribing twice is OK)
|
||||
"""
|
||||
email_lower = request.email.lower()
|
||||
|
||||
# Check if already subscribed
|
||||
result = await db.execute(
|
||||
select(NewsletterSubscriber).where(
|
||||
NewsletterSubscriber.email == email_lower
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
if existing.is_active:
|
||||
return MessageResponse(
|
||||
message="You're already subscribed to our newsletter!",
|
||||
success=True,
|
||||
)
|
||||
else:
|
||||
# Reactivate subscription
|
||||
existing.is_active = True
|
||||
existing.subscribed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
background_tasks.add_task(
|
||||
email_service.send_newsletter_welcome,
|
||||
to_email=email_lower,
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message="Welcome back! You've been re-subscribed to our newsletter.",
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Create new subscription
|
||||
import secrets
|
||||
subscriber = NewsletterSubscriber(
|
||||
email=email_lower,
|
||||
is_active=True,
|
||||
unsubscribe_token=secrets.token_urlsafe(32),
|
||||
)
|
||||
db.add(subscriber)
|
||||
await db.commit()
|
||||
|
||||
# Send welcome email
|
||||
background_tasks.add_task(
|
||||
email_service.send_newsletter_welcome,
|
||||
to_email=email_lower,
|
||||
)
|
||||
|
||||
logger.info(f"Newsletter subscription: {email_lower}")
|
||||
|
||||
return MessageResponse(
|
||||
message="Thanks for subscribing! Check your inbox for a welcome email.",
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/newsletter/unsubscribe", response_model=MessageResponse)
|
||||
async def unsubscribe_newsletter(
|
||||
request: NewsletterUnsubscribeRequest,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Unsubscribe from pounce newsletter.
|
||||
|
||||
- Marks subscription as inactive
|
||||
- Can use token for one-click unsubscribe
|
||||
"""
|
||||
email_lower = request.email.lower()
|
||||
|
||||
# Find subscription
|
||||
query = select(NewsletterSubscriber).where(
|
||||
NewsletterSubscriber.email == email_lower
|
||||
)
|
||||
|
||||
# If token provided, verify it
|
||||
if request.token:
|
||||
query = query.where(
|
||||
NewsletterSubscriber.unsubscribe_token == request.token
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
subscriber = result.scalar_one_or_none()
|
||||
|
||||
if not subscriber:
|
||||
# Always return success (don't reveal if email exists)
|
||||
return MessageResponse(
|
||||
message="If you were subscribed, you have been unsubscribed.",
|
||||
success=True,
|
||||
)
|
||||
|
||||
subscriber.is_active = False
|
||||
subscriber.unsubscribed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Newsletter unsubscription: {email_lower}")
|
||||
|
||||
return MessageResponse(
|
||||
message="You have been unsubscribed from our newsletter.",
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/newsletter/status")
|
||||
async def check_newsletter_status(
|
||||
email: EmailStr,
|
||||
db: Database,
|
||||
):
|
||||
"""Check if an email is subscribed to the newsletter."""
|
||||
result = await db.execute(
|
||||
select(NewsletterSubscriber).where(
|
||||
NewsletterSubscriber.email == email.lower()
|
||||
)
|
||||
)
|
||||
subscriber = result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"subscribed": subscriber is not None and subscriber.is_active,
|
||||
}
|
||||
|
||||
@ -1,15 +1,53 @@
|
||||
"""Subscription API endpoints."""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
"""
|
||||
Subscription API endpoints with Stripe integration.
|
||||
|
||||
Endpoints:
|
||||
- GET /subscription - Get current subscription
|
||||
- GET /subscription/tiers - Get available tiers
|
||||
- GET /subscription/features - Get current features
|
||||
- POST /subscription/checkout - Create Stripe checkout session
|
||||
- POST /subscription/portal - Create Stripe customer portal session
|
||||
- POST /subscription/cancel - Cancel subscription
|
||||
"""
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, status, Request
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.api.deps import Database, CurrentUser
|
||||
from app.models.domain import Domain
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG
|
||||
from app.schemas.subscription import SubscriptionResponse, SubscriptionTierInfo
|
||||
from app.schemas.subscription import SubscriptionResponse
|
||||
from app.services.stripe_service import StripeService, TIER_FEATURES
|
||||
from app.services.email_service import email_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
"""Request to create checkout session."""
|
||||
plan: str # "trader" or "tycoon"
|
||||
success_url: Optional[str] = None
|
||||
cancel_url: Optional[str] = None
|
||||
|
||||
|
||||
class CheckoutResponse(BaseModel):
|
||||
"""Response with checkout URL."""
|
||||
checkout_url: str
|
||||
session_id: str
|
||||
|
||||
|
||||
class PortalResponse(BaseModel):
|
||||
"""Response with portal URL."""
|
||||
portal_url: str
|
||||
|
||||
|
||||
# ============== Endpoints ==============
|
||||
|
||||
@router.get("", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
current_user: CurrentUser,
|
||||
@ -22,18 +60,23 @@ async def get_subscription(
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No subscription found",
|
||||
# Create default Scout subscription
|
||||
subscription = Subscription(
|
||||
user_id=current_user.id,
|
||||
tier=SubscriptionTier.SCOUT,
|
||||
max_domains=5,
|
||||
check_frequency="daily",
|
||||
)
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Count domains used
|
||||
domain_count = await db.execute(
|
||||
select(func.count(Domain.id)).where(Domain.user_id == current_user.id)
|
||||
)
|
||||
domains_used = domain_count.scalar()
|
||||
domains_used = domain_count.scalar() or 0
|
||||
|
||||
# Get tier config
|
||||
config = subscription.config
|
||||
|
||||
return SubscriptionResponse(
|
||||
@ -59,43 +102,53 @@ async def get_subscription_tiers():
|
||||
for tier_enum, config in TIER_CONFIG.items():
|
||||
feature_list = []
|
||||
|
||||
# Build feature list for display
|
||||
feature_list.append(f"{config['domain_limit']} domains in watchlist")
|
||||
|
||||
if config["check_frequency"] == "hourly":
|
||||
if config.get("portfolio_limit"):
|
||||
if config["portfolio_limit"] == -1:
|
||||
feature_list.append("Unlimited portfolio domains")
|
||||
elif config["portfolio_limit"] > 0:
|
||||
feature_list.append(f"{config['portfolio_limit']} portfolio domains")
|
||||
|
||||
if config["check_frequency"] == "realtime":
|
||||
feature_list.append("10-minute availability checks")
|
||||
elif config["check_frequency"] == "hourly":
|
||||
feature_list.append("Hourly availability checks")
|
||||
else:
|
||||
feature_list.append("Daily availability checks")
|
||||
|
||||
if config["features"]["priority_alerts"]:
|
||||
feature_list.append("Priority email notifications")
|
||||
else:
|
||||
if config["features"].get("sms_alerts"):
|
||||
feature_list.append("SMS & Telegram notifications")
|
||||
elif config["features"].get("email_alerts"):
|
||||
feature_list.append("Email notifications")
|
||||
|
||||
if config["features"]["full_whois"]:
|
||||
feature_list.append("Full WHOIS data")
|
||||
else:
|
||||
feature_list.append("Basic WHOIS data")
|
||||
if config["features"].get("domain_valuation"):
|
||||
feature_list.append("Domain valuation")
|
||||
|
||||
if config["features"].get("market_insights"):
|
||||
feature_list.append("Full market insights")
|
||||
|
||||
if config["history_days"] == -1:
|
||||
feature_list.append("Unlimited check history")
|
||||
elif config["history_days"] > 0:
|
||||
feature_list.append(f"{config['history_days']}-day check history")
|
||||
|
||||
if config["features"]["expiration_tracking"]:
|
||||
feature_list.append("Expiration date tracking")
|
||||
|
||||
if config["features"]["api_access"]:
|
||||
if config["features"].get("api_access"):
|
||||
feature_list.append("REST API access")
|
||||
|
||||
if config["features"]["webhooks"]:
|
||||
feature_list.append("Webhook integrations")
|
||||
if config["features"].get("bulk_tools"):
|
||||
feature_list.append("Bulk import/export tools")
|
||||
|
||||
if config["features"].get("seo_metrics"):
|
||||
feature_list.append("SEO metrics (DA, backlinks)")
|
||||
|
||||
tiers.append({
|
||||
"id": tier_enum.value,
|
||||
"name": config["name"],
|
||||
"domain_limit": config["domain_limit"],
|
||||
"portfolio_limit": config.get("portfolio_limit", 0),
|
||||
"price": config["price"],
|
||||
"currency": config.get("currency", "EUR"),
|
||||
"check_frequency": config["check_frequency"],
|
||||
"features": feature_list,
|
||||
"feature_flags": config["features"],
|
||||
@ -113,10 +166,17 @@ async def get_my_features(current_user: CurrentUser, db: Database):
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No subscription found",
|
||||
)
|
||||
# Default to Scout
|
||||
config = TIER_CONFIG[SubscriptionTier.SCOUT]
|
||||
return {
|
||||
"tier": "scout",
|
||||
"tier_name": "Scout",
|
||||
"domain_limit": config["domain_limit"],
|
||||
"portfolio_limit": config.get("portfolio_limit", 0),
|
||||
"check_frequency": config["check_frequency"],
|
||||
"history_days": config["history_days"],
|
||||
"features": config["features"],
|
||||
}
|
||||
|
||||
config = subscription.config
|
||||
|
||||
@ -124,7 +184,162 @@ async def get_my_features(current_user: CurrentUser, db: Database):
|
||||
"tier": subscription.tier.value,
|
||||
"tier_name": config["name"],
|
||||
"domain_limit": config["domain_limit"],
|
||||
"portfolio_limit": config.get("portfolio_limit", 0),
|
||||
"check_frequency": config["check_frequency"],
|
||||
"history_days": config["history_days"],
|
||||
"features": config["features"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/checkout", response_model=CheckoutResponse)
|
||||
async def create_checkout_session(
|
||||
request: CheckoutRequest,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Create a Stripe Checkout session for subscription upgrade.
|
||||
|
||||
Args:
|
||||
plan: "trader" or "tycoon"
|
||||
success_url: URL to redirect after successful payment
|
||||
cancel_url: URL to redirect if user cancels
|
||||
|
||||
Returns:
|
||||
checkout_url: Stripe Checkout page URL
|
||||
session_id: Stripe session ID
|
||||
"""
|
||||
if not StripeService.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Payment system not configured. Please contact support.",
|
||||
)
|
||||
|
||||
if request.plan not in ["trader", "tycoon"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid plan. Must be 'trader' or 'tycoon'",
|
||||
)
|
||||
|
||||
# Get site URL from environment
|
||||
site_url = os.getenv("SITE_URL", "http://localhost:3000")
|
||||
|
||||
success_url = request.success_url or f"{site_url}/dashboard?upgraded=true"
|
||||
cancel_url = request.cancel_url or f"{site_url}/pricing?cancelled=true"
|
||||
|
||||
try:
|
||||
result = await StripeService.create_checkout_session(
|
||||
user=current_user,
|
||||
plan=request.plan,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
|
||||
# Save Stripe customer ID if new
|
||||
if result.get("customer_id") and not current_user.stripe_customer_id:
|
||||
current_user.stripe_customer_id = result["customer_id"]
|
||||
await db.commit()
|
||||
|
||||
return CheckoutResponse(
|
||||
checkout_url=result["checkout_url"],
|
||||
session_id=result["session_id"],
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create checkout session: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/portal", response_model=PortalResponse)
|
||||
async def create_portal_session(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Create a Stripe Customer Portal session.
|
||||
|
||||
Users can:
|
||||
- Update payment method
|
||||
- View invoices
|
||||
- Cancel subscription
|
||||
- Update billing info
|
||||
"""
|
||||
if not StripeService.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Payment system not configured. Please contact support.",
|
||||
)
|
||||
|
||||
if not current_user.stripe_customer_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No billing account found. Please subscribe to a plan first.",
|
||||
)
|
||||
|
||||
site_url = os.getenv("SITE_URL", "http://localhost:3000")
|
||||
return_url = f"{site_url}/dashboard"
|
||||
|
||||
try:
|
||||
portal_url = await StripeService.create_portal_session(
|
||||
customer_id=current_user.stripe_customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
|
||||
return PortalResponse(portal_url=portal_url)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create portal session: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cancel")
|
||||
async def cancel_subscription(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Cancel subscription and downgrade to Scout.
|
||||
|
||||
Note: For Stripe-managed subscriptions, use the Customer Portal instead.
|
||||
This endpoint is for manual cancellation.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == current_user.id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No subscription found",
|
||||
)
|
||||
|
||||
if subscription.tier == SubscriptionTier.SCOUT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Already on free plan",
|
||||
)
|
||||
|
||||
# Downgrade to Scout
|
||||
old_tier = subscription.tier.value
|
||||
subscription.tier = SubscriptionTier.SCOUT
|
||||
subscription.max_domains = TIER_CONFIG[SubscriptionTier.SCOUT]["domain_limit"]
|
||||
subscription.check_frequency = TIER_CONFIG[SubscriptionTier.SCOUT]["check_frequency"]
|
||||
subscription.stripe_subscription_id = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "cancelled",
|
||||
"message": f"Subscription cancelled. Downgraded from {old_tier} to Scout.",
|
||||
"new_tier": "scout",
|
||||
}
|
||||
|
||||
73
backend/app/api/webhooks.py
Normal file
73
backend/app/api/webhooks.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""
|
||||
Webhook endpoints for external service integrations.
|
||||
|
||||
- Stripe payment webhooks
|
||||
- Future: Other payment providers, notification services, etc.
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Request, Header, status
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.stripe_service import StripeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/stripe")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
stripe_signature: str = Header(None, alias="Stripe-Signature"),
|
||||
):
|
||||
"""
|
||||
Handle Stripe webhook events.
|
||||
|
||||
This endpoint receives events from Stripe when:
|
||||
- Payment succeeds or fails
|
||||
- Subscription is updated or cancelled
|
||||
- Invoice is created or paid
|
||||
|
||||
The webhook must be configured in Stripe Dashboard to point to:
|
||||
https://your-domain.com/api/webhooks/stripe
|
||||
|
||||
Required Header:
|
||||
- Stripe-Signature: Stripe's webhook signature for verification
|
||||
"""
|
||||
if not stripe_signature:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing Stripe-Signature header",
|
||||
)
|
||||
|
||||
if not StripeService.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Stripe not configured",
|
||||
)
|
||||
|
||||
# Get raw body for signature verification
|
||||
payload = await request.body()
|
||||
|
||||
try:
|
||||
async for db in get_db():
|
||||
result = await StripeService.handle_webhook(
|
||||
payload=payload,
|
||||
sig_header=stripe_signature,
|
||||
db=db,
|
||||
)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Webhook validation error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook processing error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Webhook processing failed",
|
||||
)
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
"""FastAPI application entry point."""
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from app.api import api_router
|
||||
from app.config import get_settings
|
||||
@ -19,6 +24,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Rate limiter configuration
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=["200/minute"], # Global default
|
||||
storage_uri="memory://", # In-memory storage (use Redis in production)
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@ -44,21 +56,73 @@ async def lifespan(app: FastAPI):
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="Domain availability monitoring service",
|
||||
description="""
|
||||
# pounce API
|
||||
|
||||
Domain availability monitoring and portfolio management service.
|
||||
|
||||
## Features
|
||||
|
||||
- **Domain Monitoring**: Track domains and get notified when they become available
|
||||
- **TLD Pricing**: Real-time TLD price comparison across registrars
|
||||
- **Portfolio Management**: Track your domain investments and valuations
|
||||
- **Smart Pounce Auctions**: Find undervalued domains in auctions
|
||||
|
||||
## Authentication
|
||||
|
||||
Most endpoints require authentication via Bearer token.
|
||||
Get a token via POST /api/v1/auth/login
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- Default: 200 requests/minute per IP
|
||||
- Auth endpoints: 10 requests/minute
|
||||
- Contact form: 5 requests/hour
|
||||
|
||||
## Support
|
||||
|
||||
For API issues, contact support@pounce.ch
|
||||
""",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
redirect_slashes=False, # Prevent 307 redirects for trailing slashes
|
||||
redirect_slashes=False,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# Add rate limiter to app state
|
||||
app.state.limiter = limiter
|
||||
|
||||
# Custom rate limit exceeded handler
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content={
|
||||
"error": "rate_limit_exceeded",
|
||||
"detail": "Too many requests. Please slow down.",
|
||||
"retry_after": exc.detail,
|
||||
},
|
||||
)
|
||||
|
||||
# Get allowed origins from environment
|
||||
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "").split(",")
|
||||
if not ALLOWED_ORIGINS or ALLOWED_ORIGINS == [""]:
|
||||
ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://10.42.0.73:3000",
|
||||
]
|
||||
|
||||
# Add production origins
|
||||
SITE_URL = os.getenv("SITE_URL", "")
|
||||
if SITE_URL and SITE_URL not in ALLOWED_ORIGINS:
|
||||
ALLOWED_ORIGINS.append(SITE_URL)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://10.42.0.73:3000",
|
||||
# Add production origins here
|
||||
],
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@ -70,16 +134,35 @@ app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
"""Root endpoint - API info."""
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
"""Health check endpoint for monitoring."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.app_name,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# Rate-limited endpoints - apply specific limits to sensitive routes
|
||||
from fastapi import Depends
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_rate_limit_headers(request: Request, call_next):
|
||||
"""Add rate limit info to response headers."""
|
||||
response = await call_next(request)
|
||||
|
||||
# Add CORS headers for rate limit info
|
||||
response.headers["X-RateLimit-Policy"] = "200/minute"
|
||||
|
||||
return response
|
||||
|
||||
@ -5,6 +5,7 @@ from app.models.subscription import Subscription
|
||||
from app.models.tld_price import TLDPrice, TLDInfo
|
||||
from app.models.portfolio import PortfolioDomain, DomainValuation
|
||||
from app.models.auction import DomainAuction, AuctionScrapeLog
|
||||
from app.models.newsletter import NewsletterSubscriber
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -17,4 +18,5 @@ __all__ = [
|
||||
"DomainValuation",
|
||||
"DomainAuction",
|
||||
"AuctionScrapeLog",
|
||||
"NewsletterSubscriber",
|
||||
]
|
||||
|
||||
34
backend/app/models/newsletter.py
Normal file
34
backend/app/models/newsletter.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Newsletter subscriber model."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Boolean, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NewsletterSubscriber(Base):
|
||||
"""Newsletter subscriber model."""
|
||||
|
||||
__tablename__ = "newsletter_subscribers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
|
||||
# Status
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Unsubscribe token for one-click unsubscribe
|
||||
unsubscribe_token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
|
||||
# Timestamps
|
||||
subscribed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
unsubscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Optional tracking
|
||||
source: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # e.g., "homepage", "blog", "footer"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "inactive"
|
||||
return f"<NewsletterSubscriber {self.email} ({status})>"
|
||||
|
||||
@ -26,11 +26,20 @@ class User(Base):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Password Reset
|
||||
password_reset_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
password_reset_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Email Verification
|
||||
email_verification_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
email_verification_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
domains: Mapped[List["Domain"]] = relationship(
|
||||
@ -46,3 +55,11 @@ class User(Base):
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email}>"
|
||||
|
||||
# Property aliases for compatibility
|
||||
@property
|
||||
def password_hash(self) -> str:
|
||||
return self.hashed_password
|
||||
|
||||
@password_hash.setter
|
||||
def password_hash(self, value: str):
|
||||
self.hashed_password = value
|
||||
|
||||
@ -8,6 +8,8 @@ Email Types:
|
||||
- Price change notifications
|
||||
- Subscription confirmations
|
||||
- Password reset
|
||||
- Email verification
|
||||
- Contact form messages
|
||||
- Weekly digests
|
||||
|
||||
Environment Variables Required:
|
||||
@ -20,8 +22,7 @@ Environment Variables Required:
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
@ -42,166 +43,281 @@ SMTP_CONFIG = {
|
||||
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true",
|
||||
}
|
||||
|
||||
# Contact email - where contact form submissions are sent
|
||||
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "support@pounce.ch")
|
||||
|
||||
# Email Templates
|
||||
TEMPLATES = {
|
||||
"domain_available": """
|
||||
|
||||
# Base email wrapper template
|
||||
BASE_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
||||
.domain { font-family: monospace; font-size: 28px; color: #00d4aa; margin: 16px 0; }
|
||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
}
|
||||
.logo {
|
||||
color: #00d4aa;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 { color: #fff; margin: 0 0 16px 0; }
|
||||
h2 { color: #fff; margin: 24px 0 16px 0; }
|
||||
p { color: #e5e5e5; line-height: 1.6; }
|
||||
.highlight {
|
||||
font-family: monospace;
|
||||
font-size: 24px;
|
||||
color: #00d4aa;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.cta {
|
||||
display: inline-block;
|
||||
background: #00d4aa;
|
||||
color: #0a0a0a;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.cta:hover { background: #00c49a; }
|
||||
.secondary-cta {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: #00d4aa;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #00d4aa;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: 16px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.info-box {
|
||||
background: #252525;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.stat {
|
||||
background: #252525;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
|
||||
.warning { color: #f59e0b; }
|
||||
.success { color: #00d4aa; }
|
||||
.decrease { color: #00d4aa; }
|
||||
.increase { color: #ef4444; }
|
||||
.footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #333;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer a { color: #00d4aa; text-decoration: none; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 8px 0; }
|
||||
code {
|
||||
background: #252525;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #00d4aa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">🐆 pounce</div>
|
||||
<h1 style="color: #fff; margin: 0;">Domain Available!</h1>
|
||||
<p>Great news! A domain you're monitoring is now available for registration:</p>
|
||||
<div class="domain">{{ domain }}</div>
|
||||
<p>This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!</p>
|
||||
<a href="{{ register_url }}" class="cta">Register Now →</a>
|
||||
{{ content }}
|
||||
<div class="footer">
|
||||
<p>You're receiving this because you're monitoring this domain on pounce.</p>
|
||||
<p>© {{ year }} pounce. All rights reserved.</p>
|
||||
<p>
|
||||
<a href="https://pounce.ch">pounce.ch</a> ·
|
||||
<a href="https://pounce.ch/privacy">Privacy</a> ·
|
||||
<a href="https://pounce.ch/terms">Terms</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# Email Templates (content only, wrapped in BASE_TEMPLATE)
|
||||
TEMPLATES = {
|
||||
"domain_available": """
|
||||
<h1>Domain Available!</h1>
|
||||
<p>Great news! A domain you're monitoring is now available for registration:</p>
|
||||
<div class="highlight">{{ domain }}</div>
|
||||
<p>This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!</p>
|
||||
<a href="{{ register_url }}" class="cta">Register Now →</a>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
You're receiving this because you're monitoring this domain on pounce.
|
||||
</p>
|
||||
""",
|
||||
|
||||
"price_alert": """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
||||
.tld { font-family: monospace; font-size: 24px; color: #00d4aa; }
|
||||
.price-change { font-size: 20px; margin: 16px 0; }
|
||||
.decrease { color: #00d4aa; }
|
||||
.increase { color: #ef4444; }
|
||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">🐆 pounce</div>
|
||||
<h1 style="color: #fff; margin: 0;">Price Alert: <span class="tld">.{{ tld }}</span></h1>
|
||||
<p class="price-change">
|
||||
{% if change_percent < 0 %}
|
||||
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
|
||||
{% else %}
|
||||
<span class="increase">↑ Price increased {{ change_percent }}%</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Old price:</strong> ${{ old_price }}<br>
|
||||
<strong>New price:</strong> ${{ new_price }}<br>
|
||||
<strong>Cheapest registrar:</strong> {{ registrar }}
|
||||
</p>
|
||||
<a href="{{ tld_url }}" class="cta">View Details →</a>
|
||||
<div class="footer">
|
||||
<p>You're receiving this because you set a price alert for .{{ tld }} on pounce.</p>
|
||||
<p>© {{ year }} pounce. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<h1>Price Alert: <span style="color: #00d4aa;">.{{ tld }}</span></h1>
|
||||
<p style="font-size: 20px;">
|
||||
{% if change_percent < 0 %}
|
||||
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
|
||||
{% else %}
|
||||
<span class="increase">↑ Price increased {{ change_percent }}%</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="info-box">
|
||||
<p><strong>Old price:</strong> ${{ old_price }}</p>
|
||||
<p><strong>New price:</strong> ${{ new_price }}</p>
|
||||
<p><strong>Cheapest registrar:</strong> {{ registrar }}</p>
|
||||
</div>
|
||||
<a href="{{ tld_url }}" class="cta">View Details →</a>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
You're receiving this because you set a price alert for .{{ tld }} on pounce.
|
||||
</p>
|
||||
""",
|
||||
|
||||
"subscription_confirmed": """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
||||
.plan { font-size: 28px; color: #00d4aa; margin: 16px 0; }
|
||||
.features { background: #252525; padding: 16px; border-radius: 8px; margin: 16px 0; }
|
||||
.features li { margin: 8px 0; }
|
||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">🐆 pounce</div>
|
||||
<h1 style="color: #fff; margin: 0;">Welcome to {{ plan_name }}!</h1>
|
||||
<p>Your subscription is now active. Here's what you can do:</p>
|
||||
<div class="features">
|
||||
<ul>
|
||||
{% for feature in features %}
|
||||
<li>{{ feature }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
|
||||
<div class="footer">
|
||||
<p>Questions? Reply to this email or contact support@pounce.ch</p>
|
||||
<p>© {{ year }} pounce. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<h1>Welcome to {{ plan_name }}!</h1>
|
||||
<p>Your subscription is now active. Here's what you can do:</p>
|
||||
<div class="info-box">
|
||||
<ul>
|
||||
{% for feature in features %}
|
||||
<li>{{ feature }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
Questions? Reply to this email or contact support@pounce.ch
|
||||
</p>
|
||||
""",
|
||||
|
||||
"weekly_digest": """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
||||
.stat { background: #252525; padding: 16px; border-radius: 8px; margin: 8px 0; display: flex; justify-content: space-between; }
|
||||
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
|
||||
.domain { font-family: monospace; color: #00d4aa; }
|
||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">🐆 pounce</div>
|
||||
<h1 style="color: #fff; margin: 0;">Your Weekly Digest</h1>
|
||||
<p>Here's what happened with your monitored domains this week:</p>
|
||||
|
||||
<div class="stat">
|
||||
<span>Domains Monitored</span>
|
||||
<span class="stat-value">{{ total_domains }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Status Changes</span>
|
||||
<span class="stat-value">{{ status_changes }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Price Alerts</span>
|
||||
<span class="stat-value">{{ price_alerts }}</span>
|
||||
</div>
|
||||
|
||||
{% if available_domains %}
|
||||
<h2 style="color: #fff; margin-top: 24px;">Domains Now Available</h2>
|
||||
{% for domain in available_domains %}
|
||||
<p class="domain">{{ domain }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
|
||||
<div class="footer">
|
||||
<p>© {{ year }} pounce. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<h1>Your Weekly Digest</h1>
|
||||
<p>Here's what happened with your monitored domains this week:</p>
|
||||
|
||||
<div class="stat">
|
||||
<span>Domains Monitored</span>
|
||||
<span class="stat-value">{{ total_domains }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Status Changes</span>
|
||||
<span class="stat-value">{{ status_changes }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Price Alerts</span>
|
||||
<span class="stat-value">{{ price_alerts }}</span>
|
||||
</div>
|
||||
|
||||
{% if available_domains %}
|
||||
<h2>Domains Now Available</h2>
|
||||
<div class="info-box">
|
||||
{% for domain in available_domains %}
|
||||
<p class="highlight" style="margin: 8px 0;">{{ domain }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
|
||||
""",
|
||||
|
||||
"password_reset": """
|
||||
<h1>Reset Your Password</h1>
|
||||
<p>Hi {{ user_name }},</p>
|
||||
<p>We received a request to reset your password. Click the button below to set a new password:</p>
|
||||
<a href="{{ reset_url }}" class="cta">Reset Password →</a>
|
||||
<p style="margin-top: 24px;">Or copy and paste this link into your browser:</p>
|
||||
<code style="word-break: break-all;">{{ reset_url }}</code>
|
||||
<div class="info-box" style="margin-top: 24px;">
|
||||
<p class="warning" style="margin: 0;">⚠️ This link expires in 1 hour.</p>
|
||||
</div>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
If you didn't request a password reset, you can safely ignore this email.
|
||||
Your password won't be changed.
|
||||
</p>
|
||||
""",
|
||||
|
||||
"email_verification": """
|
||||
<h1>Verify Your Email</h1>
|
||||
<p>Hi {{ user_name }},</p>
|
||||
<p>Welcome to pounce! Please verify your email address to activate your account:</p>
|
||||
<a href="{{ verification_url }}" class="cta">Verify Email →</a>
|
||||
<p style="margin-top: 24px;">Or copy and paste this link into your browser:</p>
|
||||
<code style="word-break: break-all;">{{ verification_url }}</code>
|
||||
<div class="info-box" style="margin-top: 24px;">
|
||||
<p style="margin: 0;">This link expires in 24 hours.</p>
|
||||
</div>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
If you didn't create an account on pounce, you can safely ignore this email.
|
||||
</p>
|
||||
""",
|
||||
|
||||
"contact_form": """
|
||||
<h1>New Contact Form Submission</h1>
|
||||
<div class="info-box">
|
||||
<p><strong>From:</strong> {{ name }} <{{ email }}></p>
|
||||
<p><strong>Subject:</strong> {{ subject }}</p>
|
||||
<p><strong>Date:</strong> {{ timestamp }}</p>
|
||||
</div>
|
||||
<h2>Message</h2>
|
||||
<div class="info-box">
|
||||
<p style="white-space: pre-wrap;">{{ message }}</p>
|
||||
</div>
|
||||
<p style="margin-top: 24px;">
|
||||
<a href="mailto:{{ email }}" class="cta">Reply to {{ name }} →</a>
|
||||
</p>
|
||||
""",
|
||||
|
||||
"contact_confirmation": """
|
||||
<h1>We've Received Your Message</h1>
|
||||
<p>Hi {{ name }},</p>
|
||||
<p>Thank you for contacting pounce! We've received your message and will get back to you as soon as possible.</p>
|
||||
<div class="info-box">
|
||||
<p><strong>Subject:</strong> {{ subject }}</p>
|
||||
<p><strong>Your message:</strong></p>
|
||||
<p style="white-space: pre-wrap; color: #888;">{{ message }}</p>
|
||||
</div>
|
||||
<p>We typically respond within 24-48 hours during business days.</p>
|
||||
<a href="https://pounce.ch" class="secondary-cta">Back to pounce →</a>
|
||||
""",
|
||||
|
||||
"newsletter_welcome": """
|
||||
<h1>Welcome to pounce Insights!</h1>
|
||||
<p>Hi there,</p>
|
||||
<p>You're now subscribed to our newsletter. Here's what you can expect:</p>
|
||||
<div class="info-box">
|
||||
<ul>
|
||||
<li>TLD market trends and analysis</li>
|
||||
<li>Domain investing tips and strategies</li>
|
||||
<li>New feature announcements</li>
|
||||
<li>Exclusive deals and discounts</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>We typically send 1-2 emails per month. No spam, ever.</p>
|
||||
<a href="https://pounce.ch" class="cta">Explore pounce →</a>
|
||||
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||
You can unsubscribe at any time by clicking the link at the bottom of any email.
|
||||
</p>
|
||||
""",
|
||||
}
|
||||
|
||||
@ -222,6 +338,18 @@ class EmailService:
|
||||
SMTP_CONFIG["password"]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_email(template_name: str, **kwargs) -> str:
|
||||
"""Render email with base template wrapper."""
|
||||
content_template = Template(TEMPLATES.get(template_name, ""))
|
||||
content = content_template.render(**kwargs)
|
||||
|
||||
base_template = Template(BASE_TEMPLATE)
|
||||
return base_template.render(
|
||||
content=content,
|
||||
year=datetime.utcnow().year,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def send_email(
|
||||
to_email: str,
|
||||
@ -236,7 +364,7 @@ class EmailService:
|
||||
to_email: Recipient email address
|
||||
subject: Email subject
|
||||
html_content: HTML body
|
||||
text_content: Plain text body (optional, for email clients that don't support HTML)
|
||||
text_content: Plain text body (optional)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
@ -275,6 +403,8 @@ class EmailService:
|
||||
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||
return False
|
||||
|
||||
# ============== Domain Alerts ==============
|
||||
|
||||
@staticmethod
|
||||
async def send_domain_available(
|
||||
to_email: str,
|
||||
@ -283,13 +413,12 @@ class EmailService:
|
||||
) -> bool:
|
||||
"""Send domain available notification."""
|
||||
if not register_url:
|
||||
register_url = f"https://pounce.ch/dashboard"
|
||||
register_url = "https://pounce.ch/dashboard"
|
||||
|
||||
template = Template(TEMPLATES["domain_available"])
|
||||
html = template.render(
|
||||
html = EmailService._render_email(
|
||||
"domain_available",
|
||||
domain=domain,
|
||||
register_url=register_url,
|
||||
year=datetime.utcnow().year,
|
||||
)
|
||||
|
||||
return await EmailService.send_email(
|
||||
@ -310,15 +439,14 @@ class EmailService:
|
||||
"""Send TLD price change alert."""
|
||||
change_percent = round(((new_price - old_price) / old_price) * 100, 1)
|
||||
|
||||
template = Template(TEMPLATES["price_alert"])
|
||||
html = template.render(
|
||||
html = EmailService._render_email(
|
||||
"price_alert",
|
||||
tld=tld,
|
||||
old_price=f"{old_price:.2f}",
|
||||
new_price=f"{new_price:.2f}",
|
||||
change_percent=change_percent,
|
||||
registrar=registrar,
|
||||
tld_url=f"https://pounce.ch/tld-pricing/{tld}",
|
||||
year=datetime.utcnow().year,
|
||||
)
|
||||
|
||||
direction = "dropped" if change_percent < 0 else "increased"
|
||||
@ -329,6 +457,8 @@ class EmailService:
|
||||
text_content=f".{tld} price {direction} from ${old_price:.2f} to ${new_price:.2f} at {registrar}.",
|
||||
)
|
||||
|
||||
# ============== Subscription ==============
|
||||
|
||||
@staticmethod
|
||||
async def send_subscription_confirmed(
|
||||
to_email: str,
|
||||
@ -336,12 +466,11 @@ class EmailService:
|
||||
features: List[str],
|
||||
) -> bool:
|
||||
"""Send subscription confirmation email."""
|
||||
template = Template(TEMPLATES["subscription_confirmed"])
|
||||
html = template.render(
|
||||
html = EmailService._render_email(
|
||||
"subscription_confirmed",
|
||||
plan_name=plan_name,
|
||||
features=features,
|
||||
dashboard_url="https://pounce.ch/dashboard",
|
||||
year=datetime.utcnow().year,
|
||||
)
|
||||
|
||||
return await EmailService.send_email(
|
||||
@ -351,6 +480,8 @@ class EmailService:
|
||||
text_content=f"Your {plan_name} subscription is now active. Visit https://pounce.ch/dashboard to get started.",
|
||||
)
|
||||
|
||||
# ============== Digest ==============
|
||||
|
||||
@staticmethod
|
||||
async def send_weekly_digest(
|
||||
to_email: str,
|
||||
@ -360,22 +491,128 @@ class EmailService:
|
||||
available_domains: List[str],
|
||||
) -> bool:
|
||||
"""Send weekly summary digest."""
|
||||
template = Template(TEMPLATES["weekly_digest"])
|
||||
html = template.render(
|
||||
html = EmailService._render_email(
|
||||
"weekly_digest",
|
||||
total_domains=total_domains,
|
||||
status_changes=status_changes,
|
||||
price_alerts=price_alerts,
|
||||
available_domains=available_domains,
|
||||
dashboard_url="https://pounce.ch/dashboard",
|
||||
year=datetime.utcnow().year,
|
||||
)
|
||||
|
||||
return await EmailService.send_email(
|
||||
to_email=to_email,
|
||||
subject=f"📬 Your pounce Weekly Digest",
|
||||
subject="📬 Your pounce Weekly Digest",
|
||||
html_content=html,
|
||||
text_content=f"This week: {total_domains} domains monitored, {status_changes} status changes, {price_alerts} price alerts.",
|
||||
)
|
||||
|
||||
# ============== Authentication ==============
|
||||
|
||||
@staticmethod
|
||||
async def send_password_reset(
|
||||
to_email: str,
|
||||
user_name: str,
|
||||
reset_url: str,
|
||||
) -> bool:
|
||||
"""Send password reset email."""
|
||||
html = EmailService._render_email(
|
||||
"password_reset",
|
||||
user_name=user_name,
|
||||
reset_url=reset_url,
|
||||
)
|
||||
|
||||
return await EmailService.send_email(
|
||||
to_email=to_email,
|
||||
subject="🔒 Reset Your pounce Password",
|
||||
html_content=html,
|
||||
text_content=f"Hi {user_name}, reset your password by visiting: {reset_url}. This link expires in 1 hour.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def send_email_verification(
|
||||
to_email: str,
|
||||
user_name: str,
|
||||
verification_url: str,
|
||||
) -> bool:
|
||||
"""Send email verification email."""
|
||||
html = EmailService._render_email(
|
||||
"email_verification",
|
||||
user_name=user_name,
|
||||
verification_url=verification_url,
|
||||
)
|
||||
|
||||
return await EmailService.send_email(
|
||||
to_email=to_email,
|
||||
subject="✉️ Verify Your pounce Email",
|
||||
html_content=html,
|
||||
text_content=f"Hi {user_name}, verify your email by visiting: {verification_url}. This link expires in 24 hours.",
|
||||
)
|
||||
|
||||
# ============== Contact Form ==============
|
||||
|
||||
@staticmethod
|
||||
async def send_contact_form(
|
||||
name: str,
|
||||
email: str,
|
||||
subject: str,
|
||||
message: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Send contact form submission to support.
|
||||
Also sends confirmation to the user.
|
||||
"""
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
# Send to support
|
||||
support_html = EmailService._render_email(
|
||||
"contact_form",
|
||||
name=name,
|
||||
email=email,
|
||||
subject=subject,
|
||||
message=message,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
support_sent = await EmailService.send_email(
|
||||
to_email=CONTACT_EMAIL,
|
||||
subject=f"[Contact] {subject} - from {name}",
|
||||
html_content=support_html,
|
||||
text_content=f"From: {name} <{email}>\nSubject: {subject}\n\n{message}",
|
||||
)
|
||||
|
||||
# Send confirmation to user
|
||||
confirm_html = EmailService._render_email(
|
||||
"contact_confirmation",
|
||||
name=name,
|
||||
subject=subject,
|
||||
message=message,
|
||||
)
|
||||
|
||||
confirm_sent = await EmailService.send_email(
|
||||
to_email=email,
|
||||
subject="We've received your message - pounce",
|
||||
html_content=confirm_html,
|
||||
text_content=f"Hi {name}, we've received your message and will get back to you soon.",
|
||||
)
|
||||
|
||||
return support_sent # Return whether support email was sent
|
||||
|
||||
# ============== Newsletter ==============
|
||||
|
||||
@staticmethod
|
||||
async def send_newsletter_welcome(
|
||||
to_email: str,
|
||||
) -> bool:
|
||||
"""Send newsletter subscription welcome email."""
|
||||
html = EmailService._render_email("newsletter_welcome")
|
||||
|
||||
return await EmailService.send_email(
|
||||
to_email=to_email,
|
||||
subject="🎉 Welcome to pounce Insights!",
|
||||
html_content=html,
|
||||
text_content="Welcome to pounce Insights! You'll receive TLD market trends, domain investing tips, and feature announcements.",
|
||||
)
|
||||
|
||||
|
||||
# Global instance
|
||||
|
||||
@ -23,7 +23,10 @@ SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-characters
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
|
||||
# CORS Origins (comma-separated)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# Email Verification (set to "true" to require email verification before login)
|
||||
REQUIRE_EMAIL_VERIFICATION=false
|
||||
|
||||
# =================================
|
||||
# Stripe Payments
|
||||
@ -33,6 +36,7 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||
|
||||
# Price IDs from Stripe Dashboard (Products > Prices)
|
||||
# Create products "Trader" and "Tycoon" in Stripe, then get their Price IDs
|
||||
STRIPE_PRICE_TRADER=price_xxxxxxxxxxxxxx
|
||||
STRIPE_PRICE_TYCOON=price_xxxxxxxxxxxxxx
|
||||
|
||||
@ -65,6 +69,9 @@ SMTP_FROM_EMAIL=noreply@pounce.ch
|
||||
SMTP_FROM_NAME=pounce
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# Email for contact form submissions
|
||||
CONTACT_EMAIL=support@pounce.ch
|
||||
|
||||
# =================================
|
||||
# Scheduler Settings
|
||||
# =================================
|
||||
@ -86,5 +93,14 @@ ENVIRONMENT=development
|
||||
# Debug mode (disable in production!)
|
||||
DEBUG=true
|
||||
|
||||
# Site URL (for email links)
|
||||
# Site URL (for email links, password reset, etc.)
|
||||
SITE_URL=http://localhost:3000
|
||||
|
||||
# =================================
|
||||
# Rate Limiting
|
||||
# =================================
|
||||
# Default rate limit (requests per minute per IP)
|
||||
# Rate limits are enforced in API endpoints
|
||||
# Contact form: 5/hour
|
||||
# Auth (login/register): 10/minute
|
||||
# General API: 200/minute
|
||||
|
||||
@ -1,35 +1,46 @@
|
||||
# FastAPI & Server
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
python-multipart>=0.0.12
|
||||
|
||||
# Database
|
||||
sqlalchemy>=2.0.35
|
||||
aiosqlite>=0.20.0
|
||||
asyncpg>=0.30.0
|
||||
alembic>=1.14.0
|
||||
|
||||
# Authentication
|
||||
# Database
|
||||
# Domain Checking
|
||||
# Email (optional, for notifications)
|
||||
# FastAPI & Server
|
||||
# Production Database (optional)
|
||||
# Scheduling
|
||||
# Utilities
|
||||
# Web Scraping
|
||||
aiosmtplib>=3.0.2
|
||||
aiosqlite>=0.20.0
|
||||
alembic>=1.14.0
|
||||
apscheduler>=3.10.4
|
||||
asyncpg>=0.30.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
bcrypt>=4.0.0,<4.1
|
||||
beautifulsoup4>=4.12.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
|
||||
# Validation & Settings
|
||||
pydantic[email]>=2.10.0
|
||||
pydantic-settings>=2.6.0
|
||||
python-dotenv>=1.0.1
|
||||
|
||||
# Domain Checking
|
||||
python-whois>=0.9.4
|
||||
whodap>=0.1.12
|
||||
dnspython>=2.7.0
|
||||
fastapi>=0.115.0
|
||||
|
||||
# Web Scraping
|
||||
httpx>=0.28.0
|
||||
jinja2>=3.1.2
|
||||
beautifulsoup4>=4.12.0
|
||||
lxml>=5.0.0
|
||||
|
||||
# Scheduling
|
||||
apscheduler>=3.10.4
|
||||
|
||||
# Email (SMTP)
|
||||
aiosmtplib>=3.0.2
|
||||
jinja2>=3.1.2
|
||||
|
||||
# Payments
|
||||
stripe>=7.0.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
pydantic-settings>=2.6.0
|
||||
pydantic[email]>=2.10.0
|
||||
python-dotenv>=1.0.1
|
||||
python-jose[cryptography]>=3.3.0
|
||||
python-multipart>=0.0.12
|
||||
python-whois>=0.9.4
|
||||
sqlalchemy>=2.0.35
|
||||
uvicorn[standard]>=0.32.0
|
||||
whodap>=0.1.12
|
||||
|
||||
# Rate Limiting
|
||||
slowapi>=0.1.9
|
||||
|
||||
# Production Database (optional)
|
||||
# asyncpg>=0.30.0 # Already included above
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
import { useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Mail, MessageSquare, Clock, Send, Loader2, CheckCircle, MapPin, Building } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Mail, MessageSquare, Clock, Send, Loader2, CheckCircle, MapPin, Building, AlertCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const contactMethods = [
|
||||
@ -46,7 +47,8 @@ const faqs = [
|
||||
]
|
||||
|
||||
export default function ContactPage() {
|
||||
const [formState, setFormState] = useState<'idle' | 'loading' | 'success'>('idle')
|
||||
const [formState, setFormState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
@ -57,16 +59,26 @@ export default function ContactPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFormState('loading')
|
||||
setError(null)
|
||||
|
||||
// Simulate form submission
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
setFormState('success')
|
||||
|
||||
// Reset after showing success
|
||||
setTimeout(() => {
|
||||
setFormState('idle')
|
||||
setFormData({ name: '', email: '', subject: '', message: '' })
|
||||
}, 3000)
|
||||
try {
|
||||
await api.submitContact(
|
||||
formData.name,
|
||||
formData.email,
|
||||
formData.subject,
|
||||
formData.message
|
||||
)
|
||||
setFormState('success')
|
||||
|
||||
// Reset after showing success
|
||||
setTimeout(() => {
|
||||
setFormState('idle')
|
||||
setFormData({ name: '', email: '', subject: '', message: '' })
|
||||
}, 5000)
|
||||
} catch (err: any) {
|
||||
setFormState('error')
|
||||
setError(err.message || 'Failed to send message. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -132,11 +144,17 @@ export default function ContactPage() {
|
||||
</div>
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-2">Message Sent!</h3>
|
||||
<p className="text-body-sm text-foreground-muted">
|
||||
We'll get back to you within 24 hours.
|
||||
We've sent you a confirmation email. We'll get back to you within 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{formState === 'error' && error && (
|
||||
<div className="p-4 bg-danger/10 border border-danger/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger flex-shrink-0" />
|
||||
<p className="text-body-sm text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-ui-sm text-foreground-muted mb-2 block">Name</label>
|
||||
|
||||
126
frontend/src/app/forgot-password/page.tsx
Normal file
126
frontend/src/app/forgot-password/page.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { api } from '@/lib/api'
|
||||
import { Mail, ArrowLeft, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await api.forgotPassword(email)
|
||||
setSuccess(true)
|
||||
} catch (err: any) {
|
||||
// Always show success message (security: don't reveal if email exists)
|
||||
setSuccess(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
|
||||
<div className="w-full max-w-md">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to login
|
||||
</Link>
|
||||
|
||||
{success ? (
|
||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
If an account with <strong className="text-foreground">{email}</strong> exists,
|
||||
we've sent a password reset link. The link expires in 1 hour.
|
||||
</p>
|
||||
<p className="text-body-sm text-foreground-subtle">
|
||||
Didn't receive the email? Check your spam folder or{' '}
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
try again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-2">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="text-foreground-muted">
|
||||
Enter your email and we'll send you a reset link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-danger/10 border border-danger/20 rounded-xl text-danger">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<p className="text-body-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-body-sm font-medium text-foreground mb-2">
|
||||
Email address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
className="w-full pl-12 pr-4 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={clsx(
|
||||
'w-full py-3 px-4 bg-accent text-background font-medium rounded-xl transition-all',
|
||||
loading
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-accent-hover active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -108,6 +108,15 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-body-xs sm:text-body-sm text-foreground-muted hover:text-accent transition-colors duration-300"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
||||
225
frontend/src/app/reset-password/page.tsx
Normal file
225
frontend/src/app/reset-password/page.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { api } from '@/lib/api'
|
||||
import { Lock, Eye, EyeOff, CheckCircle, AlertCircle, ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Invalid or missing reset token. Please request a new password reset link.')
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const validatePassword = (pwd: string): string | null => {
|
||||
if (pwd.length < 8) {
|
||||
return 'Password must be at least 8 characters'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const passwordError = validatePassword(password)
|
||||
if (passwordError) {
|
||||
setError(passwordError)
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setError('Invalid reset token')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await api.resetPassword(token, password)
|
||||
setSuccess(true)
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login?reset=success')
|
||||
}, 3000)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to reset password. The link may have expired.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passwordStrength = (pwd: string): { label: string; color: string; width: string } => {
|
||||
if (pwd.length === 0) return { label: '', color: 'bg-transparent', width: 'w-0' }
|
||||
if (pwd.length < 6) return { label: 'Weak', color: 'bg-danger', width: 'w-1/4' }
|
||||
if (pwd.length < 8) return { label: 'Fair', color: 'bg-warning', width: 'w-2/4' }
|
||||
if (pwd.length < 12) return { label: 'Good', color: 'bg-accent', width: 'w-3/4' }
|
||||
return { label: 'Strong', color: 'bg-accent', width: 'w-full' }
|
||||
}
|
||||
|
||||
const strength = passwordStrength(password)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
|
||||
<div className="w-full max-w-md">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to login
|
||||
</Link>
|
||||
|
||||
{success ? (
|
||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
||||
Password reset successful
|
||||
</h1>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
Your password has been reset. Redirecting you to login...
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-2">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className="text-foreground-muted">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-danger/10 border border-danger/20 rounded-xl text-danger">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<p className="text-body-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-body-sm font-medium text-foreground mb-2">
|
||||
New password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="At least 8 characters"
|
||||
className="w-full pl-12 pr-12 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password strength indicator */}
|
||||
{password && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1 bg-background-tertiary rounded-full overflow-hidden">
|
||||
<div className={clsx('h-full transition-all', strength.color, strength.width)} />
|
||||
</div>
|
||||
<p className="text-ui-xs text-foreground-muted mt-1">{strength.label}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-body-sm font-medium text-foreground mb-2">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Confirm your password"
|
||||
className="w-full pl-12 pr-4 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
||||
/>
|
||||
</div>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="text-ui-xs text-danger mt-1">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !token}
|
||||
className={clsx(
|
||||
'w-full py-3 px-4 bg-accent text-background font-medium rounded-xl transition-all',
|
||||
(loading || !token)
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-accent-hover active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-foreground-muted">Loading...</div>
|
||||
</main>
|
||||
}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
128
frontend/src/app/verify-email/page.tsx
Normal file
128
frontend/src/app/verify-email/page.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { api } from '@/lib/api'
|
||||
import { Mail, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setError('Invalid or missing verification token.')
|
||||
return
|
||||
}
|
||||
|
||||
const verifyEmail = async () => {
|
||||
try {
|
||||
await api.verifyEmail(token)
|
||||
setStatus('success')
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login?verified=true')
|
||||
}, 3000)
|
||||
} catch (err: any) {
|
||||
setStatus('error')
|
||||
setError(err.message || 'Failed to verify email. The link may have expired.')
|
||||
}
|
||||
}
|
||||
|
||||
verifyEmail()
|
||||
}, [token, router])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
|
||||
<div className="w-full max-w-md">
|
||||
{status === 'loading' && (
|
||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||
</div>
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
||||
Verifying your email...
|
||||
</h1>
|
||||
<p className="text-foreground-muted">
|
||||
Please wait while we verify your email address.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
||||
Email verified!
|
||||
</h1>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
Your email has been verified successfully. Redirecting you to login...
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertCircle className="w-8 h-8 text-danger" />
|
||||
</div>
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
||||
Verification failed
|
||||
</h1>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
{error}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center w-full px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Go to login
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center justify-center w-full px-6 py-3 bg-background-tertiary text-foreground font-medium rounded-xl hover:bg-background-secondary transition-all border border-border"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-foreground-muted">Loading...</div>
|
||||
</main>
|
||||
}>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@ -106,6 +106,83 @@ class ApiClient {
|
||||
}>('/auth/me')
|
||||
}
|
||||
|
||||
// Password Reset
|
||||
async forgotPassword(email: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
})
|
||||
}
|
||||
|
||||
// Email Verification
|
||||
async verifyEmail(token: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/auth/verify-email', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
}
|
||||
|
||||
async resendVerification(email: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
}
|
||||
|
||||
// Contact Form
|
||||
async submitContact(name: string, email: string, subject: string, message: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/contact', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, email, subject, message }),
|
||||
})
|
||||
}
|
||||
|
||||
// Newsletter
|
||||
async subscribeNewsletter(email: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/contact/newsletter/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
}
|
||||
|
||||
async unsubscribeNewsletter(email: string, token?: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/contact/newsletter/unsubscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, token }),
|
||||
})
|
||||
}
|
||||
|
||||
// Subscription - Stripe Integration
|
||||
async createCheckoutSession(plan: string, successUrl?: string, cancelUrl?: string) {
|
||||
return this.request<{ checkout_url: string; session_id: string }>('/subscription/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async createPortalSession() {
|
||||
return this.request<{ portal_url: string }>('/subscription/portal', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async cancelSubscription() {
|
||||
return this.request<{ status: string; message: string; new_tier: string }>('/subscription/cancel', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Domain Check (public)
|
||||
async checkDomain(domain: string, quick = false) {
|
||||
return this.request<{
|
||||
|
||||
Reference in New Issue
Block a user