feat: Add email verification and password reset (Phase 1)

- Add email_verified, verification_token, reset_token fields to User model
- Create email_service.py with SendGrid integration
- Add auth endpoints: verify-email, resend-verification, forgot-password, reset-password
- Create frontend pages: /verify-email, /forgot-password, /reset-password
- Add forgot password link to login page
- Add PLATFORM_ENHANCEMENT_SPEC.md specification
This commit is contained in:
fullsizemalt 2025-12-21 13:28:54 -08:00
parent fd81b38c0c
commit f1d8a14f75
9 changed files with 956 additions and 5 deletions

98
backend/email_service.py Normal file
View file

@ -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"""
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">Welcome to Elmeg!</h1>
<p>Please verify your email address by clicking the button below:</p>
<a href="{verify_url}"
style="display: inline-block; background: #4F46E5; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px;
margin: 16px 0;">
Verify Email
</a>
<p style="color: #666; font-size: 14px;">
Or copy this link: {verify_url}
</p>
<p style="color: #999; font-size: 12px;">
This link expires in 48 hours.
</p>
</div>
"""
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"""
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">Password Reset</h1>
<p>You requested a password reset. Click below to set a new password:</p>
<a href="{reset_url}"
style="display: inline-block; background: #4F46E5; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px;
margin: 16px 0;">
Reset Password
</a>
<p style="color: #666; font-size: 14px;">
Or copy this link: {reset_url}
</p>
<p style="color: #999; font-size: 12px;">
This link expires in 1 hour. If you didn't request this, ignore this email.
</p>
</div>
"""
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)

View file

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

View file

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

View file

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

View file

@ -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=<secret>
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=<your-sendgrid-key>
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

View file

@ -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 (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<CardTitle>Check Your Email</CardTitle>
<CardDescription>
If an account exists with that email, we've sent password reset instructions.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Button variant="outline" asChild>
<Link href="/login">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Login
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<Mail className="h-12 w-12 text-primary mx-auto mb-4" />
<CardTitle>Forgot Password?</CardTitle>
<CardDescription>
Enter your email and we'll send you a reset link.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Sending..." : "Send Reset Link"}
</Button>
<div className="text-center">
<Link href="/login" className="text-sm text-muted-foreground hover:underline">
<ArrowLeft className="inline h-3 w-3 mr-1" />
Back to Login
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View file

@ -85,6 +85,9 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
required
/>
<Link href="/forgot-password" className="text-xs text-muted-foreground hover:underline">
Forgot password?
</Link>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">

View file

@ -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 (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<XCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<CardTitle>Invalid Link</CardTitle>
<CardDescription>
This password reset link is invalid or expired.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Button asChild>
<Link href="/forgot-password">Request New Link</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
if (success) {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<CardTitle>Password Reset!</CardTitle>
<CardDescription>
Your password has been successfully updated.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Button asChild>
<Link href="/login">Continue to Login</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<Lock className="h-12 w-12 text-primary mx-auto mb-4" />
<CardTitle>Set New Password</CardTitle>
<CardDescription>
Enter your new password below.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Resetting..." : "Reset Password"}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View file

@ -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 (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
{status === "loading" && <Loader2 className="h-6 w-6 animate-spin" />}
{status === "success" && <CheckCircle className="h-6 w-6 text-green-500" />}
{status === "error" && <XCircle className="h-6 w-6 text-red-500" />}
Email Verification
</CardTitle>
<CardDescription>
{status === "loading" && "Verifying your email..."}
{status === "success" && "Your email has been verified!"}
{status === "error" && "Verification failed"}
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">{message}</p>
{status === "success" && (
<Button asChild>
<Link href="/login">Continue to Login</Link>
</Button>
)}
{status === "error" && (
<div className="space-y-2">
<Button variant="outline" asChild>
<Link href="/login">Go to Login</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}