feat: add email verification
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# SMTP Configuration for Email Verification
|
||||
SMTP_HOST=smtp.qq.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=your_qq_email@qq.com
|
||||
SMTP_PASSWORD=your_16_digit_auth_code
|
||||
|
||||
# Frontend URL for the verification link
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
@@ -1,3 +1,4 @@
|
||||
.env
|
||||
.vscode
|
||||
nanobot-0.1.4.post4
|
||||
data
|
||||
|
||||
@@ -57,7 +57,25 @@ DataClaw 的架构主要分为三只“大钳子”:
|
||||
|
||||
准备好大显身手了吗?让我们把龙虾问数在你的本地跑起来!
|
||||
|
||||
### 1. 后端服务启动 🐍
|
||||
### 1. 配置环境变量 🔧
|
||||
|
||||
在项目根目录下,复制并重命名环境配置模板:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
请记得编辑根目录下的 `.env` 文件,填入你的实际配置(如 QQ 邮箱 SMTP 授权码等)。
|
||||
|
||||
> **QQ 邮箱获取 SMTP 授权码最新教程:**
|
||||
> 1. 登录 QQ 邮箱网页版 (mail.qq.com)
|
||||
> 2. 点击页面顶部的“设置” -> “账号”选项卡
|
||||
> 3. 向下滚动找到“POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务”
|
||||
> 4. 确保“POP3/SMTP服务”已点击“开启”
|
||||
> 5. 点击下方的“生成授权码”,使用手机 QQ 扫码或按提示发送短信
|
||||
> 6. 验证通过后将获得一串 **16位随机字母组合**,将其复制填入 `.env` 文件中的 `SMTP_PASSWORD` 字段
|
||||
|
||||
### 2. 后端服务启动 🐍
|
||||
|
||||
请确保你已安装 Python 3.10 或以上版本。
|
||||
|
||||
|
||||
+19
-1
@@ -57,7 +57,25 @@ DataClaw is divided into three main claws (components):
|
||||
|
||||
Ready to dive in? Let's get DataClaw running on your local machine!
|
||||
|
||||
### 1. Backend Setup 🐍
|
||||
### 1. Configure Environment Variables 🔧
|
||||
|
||||
In the root directory of the project, copy and rename the environment template:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Please edit the `.env` file in the root directory and fill in your actual configurations (e.g., QQ Mail SMTP Auth Code).
|
||||
|
||||
> **Guide to getting QQ Mail SMTP Auth Code:**
|
||||
> 1. Log in to QQ Mail web version (mail.qq.com)
|
||||
> 2. Click "Settings" (设置) at the top of the page -> "Account" (账号) tab
|
||||
> 3. Scroll down to find the "POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV Service" section
|
||||
> 4. Ensure "POP3/SMTP Service" is toggled to "On" (开启)
|
||||
> 5. Click "Generate Authorization Code" (生成授权码) below it, scan the QR code with mobile QQ or send an SMS as prompted
|
||||
> 6. After verification, you will get a **16-digit random letter combination**. Copy and paste it into the `SMTP_PASSWORD` field in your `.env` file
|
||||
|
||||
### 2. Backend Setup 🐍
|
||||
|
||||
Ensure you have Python 3.10+ installed.
|
||||
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.database import get_db, engine, Base
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||
from app.models.user import User, EmailVerification
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse, ResendVerificationRequest
|
||||
from app.core.security import get_password_hash, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
from datetime import timedelta
|
||||
from app.core.email import send_verification_email
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
@router.post("/auth/login")
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
@@ -45,7 +54,7 @@ def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depend
|
||||
}
|
||||
|
||||
@router.post("/auth/register", response_model=UserResponse)
|
||||
def register_user(user: UserCreate, db: Session = Depends(get_db)):
|
||||
def register_user(user: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
db_user = db.query(User).filter(User.username == user.username).first()
|
||||
if db_user:
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
@@ -58,19 +67,92 @@ def register_user(user: UserCreate, db: Session = Depends(get_db)):
|
||||
|
||||
# If this is the first user, make them an admin
|
||||
is_first_user = db.query(User).count() == 0
|
||||
is_admin = is_first_user or user.is_admin
|
||||
is_active = True if is_first_user else False
|
||||
|
||||
db_user = User(
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
hashed_password=hashed_password,
|
||||
is_active=True,
|
||||
is_admin=is_first_user or user.is_admin
|
||||
is_active=is_active,
|
||||
is_admin=is_admin
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
|
||||
if not is_active:
|
||||
token = generate_verification_token()
|
||||
hashed = hash_token(token)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
verification = EmailVerification(
|
||||
user_id=db_user.id,
|
||||
token_hash=hashed,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(verification)
|
||||
db.commit()
|
||||
|
||||
# 将用户的 email 保存到局部变量中,防止在后台任务执行前 session 关闭导致延迟加载失败
|
||||
user_email = db_user.email
|
||||
background_tasks.add_task(send_verification_email, user_email, token)
|
||||
|
||||
return db_user
|
||||
|
||||
@router.get("/auth/verify-email")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
hashed = hash_token(token)
|
||||
verification = db.query(EmailVerification).filter(
|
||||
EmailVerification.token_hash == hashed,
|
||||
EmailVerification.is_used == False
|
||||
).first()
|
||||
|
||||
if not verification:
|
||||
raise HTTPException(status_code=400, detail="Invalid or used token")
|
||||
|
||||
# Check if expired (make timezone-aware if naive)
|
||||
expires_at = verification.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="Token expired")
|
||||
|
||||
user = db.query(User).filter(User.id == verification.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.is_active = True
|
||||
verification.is_used = True
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "Email verified successfully"}
|
||||
|
||||
@router.post("/auth/resend-verification")
|
||||
def resend_verification(request: ResendVerificationRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == request.username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.is_active:
|
||||
raise HTTPException(status_code=400, detail="User already active")
|
||||
|
||||
token = generate_verification_token()
|
||||
hashed = hash_token(token)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
verification = EmailVerification(
|
||||
user_id=user.id,
|
||||
token_hash=hashed,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(verification)
|
||||
db.commit()
|
||||
|
||||
# 提取 email,避免后台任务访问已断开的 db session
|
||||
user_email = user.email
|
||||
background_tasks.add_task(send_verification_email, user_email, token)
|
||||
return {"status": "success", "message": "Verification email sent"}
|
||||
|
||||
@router.get("/users", response_model=List[UserResponse])
|
||||
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import smtplib
|
||||
import os
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
def send_verification_email(to_email: str, token: str):
|
||||
smtp_host = os.getenv("SMTP_HOST", "smtp.qq.com")
|
||||
smtp_port = int(os.getenv("SMTP_PORT", "465"))
|
||||
smtp_user = os.getenv("SMTP_USER", "")
|
||||
smtp_password = os.getenv("SMTP_PASSWORD", "")
|
||||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||
|
||||
if not smtp_user or not smtp_password:
|
||||
print("SMTP configuration is missing. Skip sending email.")
|
||||
return
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = smtp_user
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = "Please verify your email address"
|
||||
|
||||
verify_link = f"{frontend_url}/verify-email?token={token}"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Welcome to DataClaw!</h2>
|
||||
<p>Please click the link below to verify your email address and activate your account:</p>
|
||||
<p><a href="{verify_link}">{verify_link}</a></p>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
msg.attach(MIMEText(body, 'html'))
|
||||
|
||||
try:
|
||||
# Use SMTP_SSL for port 465
|
||||
server = smtplib.SMTP_SSL(smtp_host, smtp_port)
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
print(f"Verification email sent to {to_email}")
|
||||
except Exception as e:
|
||||
print(f"Failed to send email: {e}")
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
@@ -15,3 +15,16 @@ class User(Base):
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
projects = relationship("Project", back_populates="owner")
|
||||
email_verifications = relationship("EmailVerification", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
class EmailVerification(Base):
|
||||
__tablename__ = "email_verifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
token_hash = Column(String, index=True, nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_used = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="email_verifications")
|
||||
|
||||
@@ -18,6 +18,9 @@ class UserUpdate(BaseModel):
|
||||
is_admin: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
+6
-1
@@ -4,6 +4,11 @@ import binascii
|
||||
from typing import Any, Dict, List, Optional, Literal, Tuple
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载项目根目录下的 .env 文件
|
||||
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
@@ -35,7 +40,7 @@ from app.context import (
|
||||
from app.services.knowledge_index import knowledge_index_service
|
||||
from app.database import engine, Base
|
||||
# Import all models to ensure they are registered
|
||||
from app.models.user import User
|
||||
from app.models.user import User, EmailVerification
|
||||
from app.models.project import Project
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.subagent import Subagent
|
||||
|
||||
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"pydantic>=2.12.0,<3.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"python-multipart>=0.0.22",
|
||||
"python-socketio>=5.16.0,<6.0.0",
|
||||
|
||||
Generated
+2
@@ -245,6 +245,7 @@ dependencies = [
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "python-socketio" },
|
||||
@@ -292,6 +293,7 @@ requires-dist = [
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.22" },
|
||||
{ name = "python-socketio", specifier = ">=5.16.0,<6.0.0" },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { KnowledgeBases } from "./pages/KnowledgeBases";
|
||||
import { DataSources } from "./pages/DataSources";
|
||||
import { Modeling } from "./pages/Modeling";
|
||||
import { Subagents } from "./pages/Subagents";
|
||||
import { VerifyEmail } from "./pages/VerifyEmail";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
|
||||
import { ThemeToggle } from "./components/ThemeToggle";
|
||||
@@ -62,6 +63,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route path="/" element={
|
||||
|
||||
@@ -338,6 +338,15 @@
|
||||
"dontHaveAccount": "Don't have an account?",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"registrationSuccess": "Registration successful! Please login.",
|
||||
"registrationSuccessWithVerification": "Registration successful! Please check your email to verify your account.",
|
||||
"inactiveUserError": "Account is inactive. Please verify your email.",
|
||||
"resendVerification": "Resend Verification Email",
|
||||
"verificationSent": "Verification email sent. Please check your inbox.",
|
||||
"verifyEmailTitle": "Email Verification",
|
||||
"verifyingEmail": "Verifying your email...",
|
||||
"verifyEmailSuccess": "Email verified successfully! You can now log in.",
|
||||
"verifyEmailFailed": "Email verification failed or link expired.",
|
||||
"goToLogin": "Go to Login",
|
||||
"errorOccurred": "An error occurred",
|
||||
"mcpConfig": "MCP Configuration",
|
||||
"mcp": "MCP",
|
||||
|
||||
@@ -338,6 +338,15 @@
|
||||
"dontHaveAccount": "还没有账号?",
|
||||
"alreadyHaveAccount": "已经有账号了?",
|
||||
"registrationSuccess": "注册成功!请登录。",
|
||||
"registrationSuccessWithVerification": "注册成功!请前往邮箱点击验证链接激活账号。",
|
||||
"inactiveUserError": "账号未激活,请前往邮箱激活。",
|
||||
"resendVerification": "重新发送验证邮件",
|
||||
"verificationSent": "验证邮件已发送,请查收。",
|
||||
"verifyEmailTitle": "邮箱验证",
|
||||
"verifyingEmail": "正在验证您的邮箱...",
|
||||
"verifyEmailSuccess": "邮箱验证成功!您现在可以登录了。",
|
||||
"verifyEmailFailed": "邮箱验证失败或链接已过期。",
|
||||
"goToLogin": "前往登录",
|
||||
"errorOccurred": "发生了一个错误",
|
||||
"mcpConfig": "MCP 配置",
|
||||
"mcp": "MCP",
|
||||
|
||||
@@ -19,6 +19,9 @@ export function Login() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isInactiveError, setIsInactiveError] = useState(false);
|
||||
const [resendStatus, setResendStatus] = useState("");
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
|
||||
@@ -28,9 +31,26 @@ export function Login() {
|
||||
password: "",
|
||||
});
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setIsResending(true);
|
||||
setResendStatus("");
|
||||
try {
|
||||
await api.post("/api/v1/auth/resend-verification", {
|
||||
username: formData.username,
|
||||
});
|
||||
setResendStatus(t("verificationSent"));
|
||||
} catch (err: any) {
|
||||
setResendStatus(err.message || t("errorOccurred"));
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsInactiveError(false);
|
||||
setResendStatus("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
@@ -66,10 +86,17 @@ export function Login() {
|
||||
|
||||
// Auto login after successful registration
|
||||
setIsLogin(true);
|
||||
setError(t("registrationSuccess"));
|
||||
// Assuming backend returns is_active=false for users requiring verification
|
||||
// For now, we will show the verification prompt based on the translation
|
||||
setError(t("registrationSuccessWithVerification"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t("errorOccurred"));
|
||||
if (err.message && err.message.toLowerCase().includes("inactive user")) {
|
||||
setError(t("inactiveUserError"));
|
||||
setIsInactiveError(true);
|
||||
} else {
|
||||
setError(err.message || t("errorOccurred"));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -108,8 +135,27 @@ export function Login() {
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className={`p-3 rounded-lg mb-6 text-sm ${error.includes(t("registrationSuccess")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
|
||||
{error}
|
||||
<div className={`p-3 rounded-lg mb-6 text-sm flex flex-col gap-2 ${error.includes(t("registrationSuccessWithVerification")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
|
||||
<span>{error}</span>
|
||||
{isInactiveError && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResendVerification}
|
||||
disabled={isResending}
|
||||
className="w-fit mt-1 border-red-200 text-red-700 hover:bg-red-100"
|
||||
>
|
||||
{isResending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
{t("resendVerification")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resendStatus && (
|
||||
<div className={`p-3 rounded-lg mb-6 text-sm ${resendStatus.includes(t("verificationSent")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
|
||||
{resendStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function VerifyEmail() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const hasAttempted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setErrorMessage(t("verifyEmailFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAttempted.current) return;
|
||||
hasAttempted.current = true;
|
||||
|
||||
const verifyToken = async () => {
|
||||
try {
|
||||
await api.get(`/api/v1/auth/verify-email?token=${encodeURIComponent(token)}`);
|
||||
setStatus("success");
|
||||
} catch (err: any) {
|
||||
setStatus("error");
|
||||
setErrorMessage(err.message || t("verifyEmailFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
verifyToken();
|
||||
}, [searchParams, t]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-muted/50 px-4">
|
||||
<div className="w-full max-w-md bg-background rounded-2xl shadow-xl border border-border p-8 text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
{status === "loading" && <Loader2 className="h-16 w-16 text-indigo-600 animate-spin" />}
|
||||
{status === "success" && <CheckCircle2 className="h-16 w-16 text-emerald-500" />}
|
||||
{status === "error" && <XCircle className="h-16 w-16 text-red-500" />}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-foreground/90 mb-4">
|
||||
{t("verifyEmailTitle")}
|
||||
</h2>
|
||||
|
||||
<div className="mb-8 text-muted-foreground">
|
||||
{status === "loading" && <p>{t("verifyingEmail")}</p>}
|
||||
{status === "success" && <p className="text-emerald-600 font-medium">{t("verifyEmailSuccess")}</p>}
|
||||
{status === "error" && <p className="text-red-600 font-medium">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate("/login")}
|
||||
className="w-full h-11 bg-indigo-600 hover:bg-indigo-700 text-primary-foreground font-medium text-base rounded-xl transition-all shadow-md"
|
||||
>
|
||||
{t("goToLogin")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user