diff --git a/backend/email_service.py b/backend/email_service.py
new file mode 100644
index 0000000..e32a2dc
--- /dev/null
+++ b/backend/email_service.py
@@ -0,0 +1,98 @@
+"""
+Email Service - SendGrid integration for verification and password reset emails.
+"""
+import os
+import secrets
+from datetime import datetime, timedelta
+from typing import Optional
+import httpx
+
+SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY", "")
+EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
+FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.runfoo.run")
+
+async def send_email(to: str, subject: str, html_content: str) -> bool:
+ """Send email via SendGrid API"""
+ if not SENDGRID_API_KEY:
+ print(f"[EMAIL DEV MODE] To: {to}, Subject: {subject}")
+ print(f"[EMAIL DEV MODE] Content: {html_content[:200]}...")
+ return True
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ "https://api.sendgrid.com/v3/mail/send",
+ headers={
+ "Authorization": f"Bearer {SENDGRID_API_KEY}",
+ "Content-Type": "application/json"
+ },
+ json={
+ "personalizations": [{"to": [{"email": to}]}],
+ "from": {"email": EMAIL_FROM, "name": "Elmeg"},
+ "subject": subject,
+ "content": [{"type": "text/html", "value": html_content}]
+ }
+ )
+ return response.status_code in (200, 202)
+
+def generate_token() -> str:
+ """Generate a secure random token"""
+ return secrets.token_urlsafe(32)
+
+async def send_verification_email(email: str, token: str) -> bool:
+ """Send email verification link"""
+ verify_url = f"{FRONTEND_URL}/verify-email?token={token}"
+
+ html = f"""
+
+
Welcome to Elmeg!
+
Please verify your email address by clicking the button below:
+
+ Verify Email
+
+
+ Or copy this link: {verify_url}
+
+
+ This link expires in 48 hours.
+
+
+ """
+
+ return await send_email(email, "Verify your Elmeg account", html)
+
+async def send_password_reset_email(email: str, token: str) -> bool:
+ """Send password reset link"""
+ reset_url = f"{FRONTEND_URL}/reset-password?token={token}"
+
+ html = f"""
+
+
Password Reset
+
You requested a password reset. Click below to set a new password:
+
+ Reset Password
+
+
+ Or copy this link: {reset_url}
+
+
+ This link expires in 1 hour. If you didn't request this, ignore this email.
+
+
+ """
+
+ return await send_email(email, "Reset your Elmeg password", html)
+
+# Token expiration helpers
+def get_verification_expiry() -> datetime:
+ """48 hour expiry for email verification"""
+ return datetime.utcnow() + timedelta(hours=48)
+
+def get_reset_expiry() -> datetime:
+ """1 hour expiry for password reset"""
+ return datetime.utcnow() + timedelta(hours=1)
diff --git a/backend/migrations/add_email_verification.py b/backend/migrations/add_email_verification.py
new file mode 100644
index 0000000..b70f9a1
--- /dev/null
+++ b/backend/migrations/add_email_verification.py
@@ -0,0 +1,34 @@
+"""
+Migration to add email verification and password reset columns to user table.
+"""
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from sqlmodel import Session, create_engine, text
+from database import DATABASE_URL
+
+def add_email_verification_columns():
+ engine = create_engine(DATABASE_URL)
+
+ columns = [
+ ('email_verified', 'BOOLEAN DEFAULT FALSE'),
+ ('verification_token', 'VARCHAR'),
+ ('verification_token_expires', 'TIMESTAMP'),
+ ('reset_token', 'VARCHAR'),
+ ('reset_token_expires', 'TIMESTAMP'),
+ ]
+
+ with Session(engine) as session:
+ for col_name, col_type in columns:
+ try:
+ session.exec(text(f"""
+ ALTER TABLE "user" ADD COLUMN IF NOT EXISTS {col_name} {col_type}
+ """))
+ session.commit()
+ print(f"✅ Added {col_name} to user")
+ except Exception as e:
+ print(f"⚠️ {col_name}: {e}")
+
+if __name__ == "__main__":
+ add_email_verification_columns()
diff --git a/backend/models.py b/backend/models.py
index 1771791..51b8632 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -167,6 +167,15 @@ class User(SQLModel, table=True):
bio: Optional[str] = Field(default=None)
avatar: Optional[str] = Field(default=None)
+ # Email verification
+ email_verified: bool = Field(default=False)
+ verification_token: Optional[str] = Field(default=None)
+ verification_token_expires: Optional[datetime] = Field(default=None)
+
+ # Password reset
+ reset_token: Optional[str] = Field(default=None)
+ reset_token_expires: Optional[datetime] = Field(default=None)
+
# Multi-identity support: A user can have multiple Profiles
profiles: List["Profile"] = Relationship(back_populates="user")
comments: List["Comment"] = Relationship(back_populates="user")
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
index 48b849b..8101782 100644
--- a/backend/routers/auth.py
+++ b/backend/routers/auth.py
@@ -1,24 +1,56 @@
-from datetime import timedelta
+from datetime import timedelta, datetime
from typing import Annotated
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlmodel import Session, select
+from pydantic import BaseModel, EmailStr
from database import get_session
from models import User, Profile
from schemas import UserCreate, Token, UserRead
from auth import verify_password, get_password_hash, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, get_current_user
+from email_service import (
+ send_verification_email, send_password_reset_email,
+ generate_token, get_verification_expiry, get_reset_expiry
+)
router = APIRouter(prefix="/auth", tags=["auth"])
+# Request/Response schemas for new endpoints
+class VerifyEmailRequest(BaseModel):
+ token: str
+
+class ForgotPasswordRequest(BaseModel):
+ email: EmailStr
+
+class ResetPasswordRequest(BaseModel):
+ token: str
+ new_password: str
+
+class ResendVerificationRequest(BaseModel):
+ email: EmailStr
+
+
@router.post("/register", response_model=UserRead)
-def register(user_in: UserCreate, session: Session = Depends(get_session)):
+async def register(
+ user_in: UserCreate,
+ background_tasks: BackgroundTasks,
+ session: Session = Depends(get_session)
+):
user = session.exec(select(User).where(User.email == user_in.email)).first()
if user:
raise HTTPException(status_code=400, detail="Email already registered")
- # Create User
+ # Create User with verification token
hashed_password = get_password_hash(user_in.password)
- db_user = User(email=user_in.email, hashed_password=hashed_password)
+ verification_token = generate_token()
+
+ db_user = User(
+ email=user_in.email,
+ hashed_password=hashed_password,
+ email_verified=False,
+ verification_token=verification_token,
+ verification_token_expires=get_verification_expiry()
+ )
session.add(db_user)
session.commit()
session.refresh(db_user)
@@ -28,8 +60,112 @@ def register(user_in: UserCreate, session: Session = Depends(get_session)):
session.add(profile)
session.commit()
+ # Send verification email in background
+ background_tasks.add_task(send_verification_email, db_user.email, verification_token)
+
return db_user
+
+@router.post("/verify-email")
+def verify_email(request: VerifyEmailRequest, session: Session = Depends(get_session)):
+ """Verify user's email with token"""
+ user = session.exec(
+ select(User).where(User.verification_token == request.token)
+ ).first()
+
+ if not user:
+ raise HTTPException(status_code=400, detail="Invalid verification token")
+
+ if user.verification_token_expires and user.verification_token_expires < datetime.utcnow():
+ raise HTTPException(status_code=400, detail="Verification token expired")
+
+ if user.email_verified:
+ return {"message": "Email already verified"}
+
+ # Mark as verified
+ user.email_verified = True
+ user.verification_token = None
+ user.verification_token_expires = None
+ session.add(user)
+ session.commit()
+
+ return {"message": "Email verified successfully"}
+
+
+@router.post("/resend-verification")
+async def resend_verification(
+ request: ResendVerificationRequest,
+ background_tasks: BackgroundTasks,
+ session: Session = Depends(get_session)
+):
+ """Resend verification email"""
+ user = session.exec(select(User).where(User.email == request.email)).first()
+
+ if not user:
+ # Don't reveal if email exists
+ return {"message": "If the email exists, a verification link has been sent"}
+
+ if user.email_verified:
+ return {"message": "Email already verified"}
+
+ # Generate new token
+ user.verification_token = generate_token()
+ user.verification_token_expires = get_verification_expiry()
+ session.add(user)
+ session.commit()
+
+ background_tasks.add_task(send_verification_email, user.email, user.verification_token)
+
+ return {"message": "If the email exists, a verification link has been sent"}
+
+
+@router.post("/forgot-password")
+async def forgot_password(
+ request: ForgotPasswordRequest,
+ background_tasks: BackgroundTasks,
+ session: Session = Depends(get_session)
+):
+ """Request password reset email"""
+ user = session.exec(select(User).where(User.email == request.email)).first()
+
+ if not user:
+ # Don't reveal if email exists
+ return {"message": "If the email exists, a reset link has been sent"}
+
+ # Generate reset token
+ user.reset_token = generate_token()
+ user.reset_token_expires = get_reset_expiry()
+ session.add(user)
+ session.commit()
+
+ background_tasks.add_task(send_password_reset_email, user.email, user.reset_token)
+
+ return {"message": "If the email exists, a reset link has been sent"}
+
+
+@router.post("/reset-password")
+def reset_password(request: ResetPasswordRequest, session: Session = Depends(get_session)):
+ """Reset password with token"""
+ user = session.exec(
+ select(User).where(User.reset_token == request.token)
+ ).first()
+
+ if not user:
+ raise HTTPException(status_code=400, detail="Invalid reset token")
+
+ if user.reset_token_expires and user.reset_token_expires < datetime.utcnow():
+ raise HTTPException(status_code=400, detail="Reset token expired")
+
+ # Update password
+ user.hashed_password = get_password_hash(request.new_password)
+ user.reset_token = None
+ user.reset_token_expires = None
+ session.add(user)
+ session.commit()
+
+ return {"message": "Password reset successfully"}
+
+
@router.post("/token", response_model=Token)
def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
@@ -49,6 +185,8 @@ def login_for_access_token(
)
return {"access_token": access_token, "token_type": "bearer"}
+
@router.get("/users/me", response_model=UserRead)
def read_users_me(current_user: Annotated[User, Depends(get_current_user)]):
return current_user
+
diff --git a/docs/PLATFORM_ENHANCEMENT_SPEC.md b/docs/PLATFORM_ENHANCEMENT_SPEC.md
new file mode 100644
index 0000000..f70331b
--- /dev/null
+++ b/docs/PLATFORM_ENHANCEMENT_SPEC.md
@@ -0,0 +1,323 @@
+# Platform Enhancement Spec v2.0
+
+> **Sprint Goal**: Complete user lifecycle management, robust admin tools, and enhanced content features.
+
+---
+
+## Phase 1: User Account Lifecycle
+
+### 1.1 Email Verification
+
+**Goal**: Ensure valid email addresses and reduce spam accounts.
+
+#### User Stories
+
+- As a **new user**, I want to receive a verification email so I can confirm my account.
+- As a **user**, I want to resend verification if I didn't receive it.
+- As an **unverified user**, I see limited functionality until verified.
+
+#### Implementation
+
+1. **Model Changes** - Add to `User`:
+ - `email_verified: bool = Field(default=False)`
+ - `verification_token: Optional[str]`
+ - `verification_token_expires: Optional[datetime]`
+
+2. **API Endpoints**:
+
+ | Method | Endpoint | Description |
+ |--------|----------|-------------|
+ | POST | `/auth/register` | Creates user + sends verification email |
+ | POST | `/auth/verify-email` | Verifies token, sets `email_verified=True` |
+ | POST | `/auth/resend-verification` | Generates new token, sends email |
+
+3. **Frontend Pages**:
+ - `/verify-email?token=xxx` - Handles verification link
+ - Registration success page prompts to check email
+
+4. **Email Template**: HTML email with verification link (48hr expiry)
+
+---
+
+### 1.2 Password Reset
+
+**Goal**: Self-service password recovery without admin intervention.
+
+#### User Stories
+
+- As a **user**, I want to reset my password if I forgot it.
+- As a **user**, I want a secure, time-limited reset link.
+
+#### Implementation
+
+1. **Model Changes** - Add to `User`:
+ - `reset_token: Optional[str]`
+ - `reset_token_expires: Optional[datetime]`
+
+2. **API Endpoints**:
+
+ | Method | Endpoint | Description |
+ |--------|----------|-------------|
+ | POST | `/auth/forgot-password` | Sends reset email (rate limited) |
+ | POST | `/auth/reset-password` | Validates token + sets new password |
+
+3. **Frontend Pages**:
+ - `/forgot-password` - Email input form
+ - `/reset-password?token=xxx` - New password form
+
+4. **Security**:
+ - Tokens expire in 1 hour
+ - Single-use tokens (invalidated after use)
+ - Rate limiting on forgot-password endpoint
+
+---
+
+### 1.3 Email Service Abstraction
+
+**Goal**: Provider-agnostic email sending.
+
+#### Implementation
+
+Create `backend/email_service.py`:
+
+```python
+class EmailService:
+ async def send_verification_email(user, token)
+ async def send_password_reset_email(user, token)
+ async def send_notification_email(user, subject, body)
+```
+
+#### Environment Variables
+
+```env
+SMTP_HOST=smtp.sendgrid.net
+SMTP_PORT=587
+SMTP_USER=apikey
+SMTP_PASSWORD=
+EMAIL_FROM=noreply@elmeg.xyz
+```
+
+---
+
+## Phase 2: Admin Panel (`/admin`)
+
+### 2.1 Overview
+
+**Goal**: Full content management for administrators.
+
+#### User Stories
+
+- As an **admin**, I want to manage all users (roles, bans, verification status).
+- As an **admin**, I want to CRUD shows, songs, venues, and tours.
+- As an **admin**, I want to see platform statistics.
+
+---
+
+### 2.2 Features
+
+#### Users Tab
+
+- DataTable with search/filter
+- Columns: Email, Username, Role, Verified, Active, Joined
+- Actions: Edit role, Toggle ban, Force verify, View activity
+
+#### Content Tabs (Shows, Songs, Venues, Tours)
+
+- DataTable with CRUD actions
+- Create/Edit modals with form validation
+- YouTube link fields for Shows/Songs/Performances
+- Bulk delete with confirmation
+
+#### Stats Dashboard
+
+- Total users, verified users
+- Total shows, songs, venues
+- Recent signups chart
+- Activity heatmap
+
+---
+
+### 2.3 Access Control
+
+```python
+# backend/routers/admin.py
+allow_admin = RoleChecker(["admin"])
+
+@router.get("/users")
+def list_users(user: User = Depends(allow_admin), ...):
+```
+
+---
+
+## Phase 3: Enhanced Mod Panel (`/mod`)
+
+### 3.1 Current Features
+
+- ✅ Nickname approval queue
+- ✅ Report queue (comments, reviews)
+
+### 3.2 New Features
+
+#### User Lookup
+
+- Search user by email/username
+- View user's full activity history:
+ - Comments, reviews, ratings
+ - Attendance history
+ - Reports submitted/received
+
+#### Temp Bans
+
+- Ban duration selector (1hr, 24hr, 7d, 30d, permanent)
+- Ban reason (required)
+- Auto-unban via scheduled job
+
+#### Bulk Actions
+
+- Select multiple reports → Dismiss All / Resolve All
+- Select multiple nicknames → Approve All / Reject All
+
+#### Audit Log
+
+- Recent moderation actions by all mods
+- Who did what, when
+
+---
+
+## Phase 4: YouTube Integration
+
+### 4.1 Current State
+
+- ✅ `youtube_link` field on Show, Song, Performance
+- ✅ `YouTubeEmbed` component
+- ✅ Show detail page displays embed
+
+### 4.2 Enhancements
+
+#### Song Page YouTube
+
+- Display YouTube embed of **#1 Heady Version**
+- Performance list shows YouTube icon if link exists
+
+#### Admin Integration
+
+- YouTube URL field in Show/Song/Performance edit forms
+- URL validation (must be valid YouTube URL)
+
+---
+
+## Phase 5: Song Page Enhancement ("Heady Version")
+
+### 5.1 Concept
+
+A **Song** is the abstract composition (e.g., "Hungersite").
+A **Performance** is a specific rendition (e.g., "Hungersite @ Red Rocks 2023").
+
+The "Heady Version" is the highest-rated performance of that song.
+
+---
+
+### 5.2 Song Page Layout
+
+```
+┌─────────────────────────────────────────────────────┐
+│ 🎵 HUNGERSITE │
+│ Original: Goose │ Times Played: 127 │
+├─────────────────────────────────────────────────────┤
+│ ▶ HEADY VERSION │
+│ [YouTube Embed of #1 Performance] │
+│ 2023-07-21 @ Red Rocks ★ 9.4 (47 ratings) │
+├─────────────────────────────────────────────────────┤
+│ 📊 HEADY LEADERBOARD │
+│ 🥇 Red Rocks 2023-07-21 9.4★ (47) │
+│ 🥈 MSG 2024-03-15 9.1★ (32) │
+│ 🥉 Legend Valley 2022-09-10 8.9★ (28) │
+│ 4. Dillon 2023-08-15 8.7★ (19) │
+│ 5. The Capitol 2024-01-20 8.5★ (22) │
+├─────────────────────────────────────────────────────┤
+│ 📈 RATING TREND │
+│ [Line chart: avg rating over time] │
+├─────────────────────────────────────────────────────┤
+│ 📅 ALL PERFORMANCES │
+│ [Sortable by: Date | Rating] │
+│ ┌─────────────────────────────────────────────────┐ │
+│ │ 2024-11-15 @ Orpheum 8.2★ ▶ [YouTube] │ │
+│ │ 2024-10-20 @ Red Rocks 9.4★ ▶ [YouTube] │ │
+│ │ ... │ │
+│ └─────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────┘
+```
+
+---
+
+### 5.3 API Endpoints
+
+| Endpoint | Returns |
+|----------|---------|
+| `GET /songs/{id}` | Song + stats (times_played, avg_rating, heady_version) |
+| `GET /songs/{id}/performances` | All performances with ratings, sorted |
+| `GET /songs/{id}/heady-version` | Top-rated performance with YouTube link |
+
+---
+
+## Migration
+
+### Database Changes
+
+```sql
+ALTER TABLE "user" ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
+ALTER TABLE "user" ADD COLUMN verification_token VARCHAR;
+ALTER TABLE "user" ADD COLUMN verification_token_expires TIMESTAMP;
+ALTER TABLE "user" ADD COLUMN reset_token VARCHAR;
+ALTER TABLE "user" ADD COLUMN reset_token_expires TIMESTAMP;
+```
+
+---
+
+## Environment Requirements
+
+```env
+# Required for email
+SMTP_HOST=smtp.sendgrid.net
+SMTP_PORT=587
+SMTP_USER=apikey
+SMTP_PASSWORD=
+EMAIL_FROM=noreply@elmeg.xyz
+```
+
+---
+
+## Acceptance Criteria
+
+### Phase 1
+
+- [ ] User registers → receives verification email
+- [ ] User clicks link → account verified
+- [ ] Unverified user sees "verify email" banner
+- [ ] User requests password reset → receives email
+- [ ] User resets password → can login with new password
+
+### Phase 2
+
+- [ ] Admin can list/search all users
+- [ ] Admin can change user roles
+- [ ] Admin can CRUD shows/songs/venues/tours
+- [ ] Admin can add YouTube links via UI
+
+### Phase 3
+
+- [ ] Mod can lookup user activity
+- [ ] Mod can temp ban users
+- [ ] Mod can bulk approve/reject
+
+### Phase 4
+
+- [ ] YouTube embeds on song pages
+- [ ] YouTube icons on performance lists
+
+### Phase 5
+
+- [ ] Song page shows Heady Version embed
+- [ ] Song page shows Heady Leaderboard
+- [ ] Song page shows rating trend chart
+- [ ] Performance list sortable by date/rating
diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx
new file mode 100644
index 0000000..ab080dc
--- /dev/null
+++ b/frontend/app/forgot-password/page.tsx
@@ -0,0 +1,110 @@
+"use client"
+
+import { useState } from "react"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Mail, ArrowLeft, CheckCircle } from "lucide-react"
+import Link from "next/link"
+import { getApiUrl } from "@/lib/api-config"
+
+export default function ForgotPasswordPage() {
+ const [email, setEmail] = useState("")
+ const [loading, setLoading] = useState(false)
+ const [submitted, setSubmitted] = useState(false)
+ const [error, setError] = useState("")
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setLoading(true)
+ setError("")
+
+ try {
+ const res = await fetch(`${getApiUrl()}/auth/forgot-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email })
+ })
+
+ if (res.ok) {
+ setSubmitted(true)
+ } else {
+ const data = await res.json()
+ setError(data.detail || "Failed to send reset email")
+ }
+ } catch (e) {
+ setError("An error occurred. Please try again.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (submitted) {
+ return (
+
+
+
+
+ Check Your Email
+
+ If an account exists with that email, we've sent password reset instructions.
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Forgot Password?
+
+ Enter your email and we'll send you a reset link.
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
index 63a8915..be3f7f2 100644
--- a/frontend/app/login/page.tsx
+++ b/frontend/app/login/page.tsx
@@ -85,6 +85,9 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
required
/>
+
+ Forgot password?
+
diff --git a/frontend/app/reset-password/page.tsx b/frontend/app/reset-password/page.tsx
new file mode 100644
index 0000000..4bab9ae
--- /dev/null
+++ b/frontend/app/reset-password/page.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import { useState } from "react"
+import { useSearchParams } from "next/navigation"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Lock, CheckCircle, XCircle } from "lucide-react"
+import Link from "next/link"
+import { getApiUrl } from "@/lib/api-config"
+
+export default function ResetPasswordPage() {
+ const searchParams = useSearchParams()
+ const token = searchParams.get("token")
+
+ const [password, setPassword] = useState("")
+ const [confirmPassword, setConfirmPassword] = useState("")
+ const [loading, setLoading] = useState(false)
+ const [success, setSuccess] = useState(false)
+ const [error, setError] = useState("")
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError("")
+
+ if (password !== confirmPassword) {
+ setError("Passwords don't match")
+ return
+ }
+
+ if (password.length < 8) {
+ setError("Password must be at least 8 characters")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ const res = await fetch(`${getApiUrl()}/auth/reset-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, new_password: password })
+ })
+
+ if (res.ok) {
+ setSuccess(true)
+ } else {
+ const data = await res.json()
+ setError(data.detail || "Failed to reset password")
+ }
+ } catch (_) {
+ setError("An error occurred. Please try again.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (!token) {
+ return (
+
+
+
+
+ Invalid Link
+
+ This password reset link is invalid or expired.
+
+
+
+
+
+
+
+ )
+ }
+
+ if (success) {
+ return (
+
+
+
+
+ Password Reset!
+
+ Your password has been successfully updated.
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ Set New Password
+
+ Enter your new password below.
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/verify-email/page.tsx b/frontend/app/verify-email/page.tsx
new file mode 100644
index 0000000..c493022
--- /dev/null
+++ b/frontend/app/verify-email/page.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useSearchParams } from "next/navigation"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { CheckCircle, XCircle, Loader2 } from "lucide-react"
+import Link from "next/link"
+import { getApiUrl } from "@/lib/api-config"
+
+export default function VerifyEmailPage() {
+ const searchParams = useSearchParams()
+ const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
+ const [message, setMessage] = useState("")
+
+ const verifyEmail = async (token: string) => {
+ try {
+ const res = await fetch(`${getApiUrl()}/auth/verify-email`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token })
+ })
+
+ const data = await res.json()
+
+ if (res.ok) {
+ setStatus("success")
+ setMessage(data.message || "Email verified successfully!")
+ } else {
+ setStatus("error")
+ setMessage(data.detail || "Verification failed")
+ }
+ } catch (_) {
+ setStatus("error")
+ setMessage("An error occurred during verification")
+ }
+ }
+
+ useEffect(() => {
+ const token = searchParams.get("token")
+ if (!token) {
+ setStatus("error")
+ setMessage("No verification token provided")
+ return
+ }
+
+ verifyEmail(token)
+ }, [searchParams])
+
+ return (
+
+
+
+
+ {status === "loading" && }
+ {status === "success" && }
+ {status === "error" && }
+ Email Verification
+
+
+ {status === "loading" && "Verifying your email..."}
+ {status === "success" && "Your email has been verified!"}
+ {status === "error" && "Verification failed"}
+
+
+
+ {message}
+
+ {status === "success" && (
+
+ )}
+
+ {status === "error" && (
+
+
+
+ )}
+
+
+
+ )
+}