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. + + + +
+
+ + setEmail(e.target.value)} + required + /> +
+ + {error && ( +

{error}

+ )} + + + +
+ + + Back to Login + +
+
+
+
+
+ ) +} 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. + + + +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+
+ ) +} 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" && ( +
+ +
+ )} +
+
+
+ ) +}