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
|
.vscode
|
||||||
nanobot-0.1.4.post4
|
nanobot-0.1.4.post4
|
||||||
data
|
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 或以上版本。
|
请确保你已安装 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!
|
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.
|
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 fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from app.database import get_db, engine, Base
|
from app.database import get_db, engine, Base
|
||||||
from app.models.user import User
|
from app.models.user import User, EmailVerification
|
||||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse
|
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 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
|
# Create tables
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.post("/auth/login")
|
||||||
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||||
user = db.query(User).filter(User.username == form_data.username).first()
|
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)
|
@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()
|
db_user = db.query(User).filter(User.username == user.username).first()
|
||||||
if db_user:
|
if db_user:
|
||||||
raise HTTPException(status_code=400, detail="Username already registered")
|
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
|
# If this is the first user, make them an admin
|
||||||
is_first_user = db.query(User).count() == 0
|
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(
|
db_user = User(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
hashed_password=hashed_password,
|
hashed_password=hashed_password,
|
||||||
is_active=True,
|
is_active=is_active,
|
||||||
is_admin=is_first_user or user.is_admin
|
is_admin=is_admin
|
||||||
)
|
)
|
||||||
db.add(db_user)
|
db.add(db_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_user)
|
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
|
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])
|
@router.get("/users", response_model=List[UserResponse])
|
||||||
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
users = db.query(User).offset(skip).limit(limit).all()
|
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.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -15,3 +15,16 @@ class User(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
projects = relationship("Project", back_populates="owner")
|
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
|
is_admin: Optional[bool] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
class ResendVerificationRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
+6
-1
@@ -4,6 +4,11 @@ import binascii
|
|||||||
from typing import Any, Dict, List, Optional, Literal, Tuple
|
from typing import Any, Dict, List, Optional, Literal, Tuple
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
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 import FastAPI, HTTPException, Query
|
||||||
from fastapi.encoders import jsonable_encoder
|
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.services.knowledge_index import knowledge_index_service
|
||||||
from app.database import engine, Base
|
from app.database import engine, Base
|
||||||
# Import all models to ensure they are registered
|
# 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.project import Project
|
||||||
from app.models.datasource import DataSource
|
from app.models.datasource import DataSource
|
||||||
from app.models.subagent import Subagent
|
from app.models.subagent import Subagent
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ dependencies = [
|
|||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.11",
|
||||||
"pydantic>=2.12.0,<3.0.0",
|
"pydantic>=2.12.0,<3.0.0",
|
||||||
"pydantic-settings>=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-jose[cryptography]>=3.5.0",
|
||||||
"python-multipart>=0.0.22",
|
"python-multipart>=0.0.22",
|
||||||
"python-socketio>=5.16.0,<6.0.0",
|
"python-socketio>=5.16.0,<6.0.0",
|
||||||
|
|||||||
Generated
+2
@@ -245,6 +245,7 @@ dependencies = [
|
|||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
{ name = "python-jose", extra = ["cryptography"] },
|
{ name = "python-jose", extra = ["cryptography"] },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "python-socketio" },
|
{ name = "python-socketio" },
|
||||||
@@ -292,6 +293,7 @@ requires-dist = [
|
|||||||
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||||
{ name = "pydantic-settings", 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-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.22" },
|
{ name = "python-multipart", specifier = ">=0.0.22" },
|
||||||
{ name = "python-socketio", specifier = ">=5.16.0,<6.0.0" },
|
{ 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 { DataSources } from "./pages/DataSources";
|
||||||
import { Modeling } from "./pages/Modeling";
|
import { Modeling } from "./pages/Modeling";
|
||||||
import { Subagents } from "./pages/Subagents";
|
import { Subagents } from "./pages/Subagents";
|
||||||
|
import { VerifyEmail } from "./pages/VerifyEmail";
|
||||||
import { useAuthStore } from "./store/authStore";
|
import { useAuthStore } from "./store/authStore";
|
||||||
|
|
||||||
import { ThemeToggle } from "./components/ThemeToggle";
|
import { ThemeToggle } from "./components/ThemeToggle";
|
||||||
@@ -62,6 +63,7 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
|
||||||
{/* Protected Routes */}
|
{/* Protected Routes */}
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
|
|||||||
@@ -338,6 +338,15 @@
|
|||||||
"dontHaveAccount": "Don't have an account?",
|
"dontHaveAccount": "Don't have an account?",
|
||||||
"alreadyHaveAccount": "Already have an account?",
|
"alreadyHaveAccount": "Already have an account?",
|
||||||
"registrationSuccess": "Registration successful! Please login.",
|
"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",
|
"errorOccurred": "An error occurred",
|
||||||
"mcpConfig": "MCP Configuration",
|
"mcpConfig": "MCP Configuration",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
|
|||||||
@@ -338,6 +338,15 @@
|
|||||||
"dontHaveAccount": "还没有账号?",
|
"dontHaveAccount": "还没有账号?",
|
||||||
"alreadyHaveAccount": "已经有账号了?",
|
"alreadyHaveAccount": "已经有账号了?",
|
||||||
"registrationSuccess": "注册成功!请登录。",
|
"registrationSuccess": "注册成功!请登录。",
|
||||||
|
"registrationSuccessWithVerification": "注册成功!请前往邮箱点击验证链接激活账号。",
|
||||||
|
"inactiveUserError": "账号未激活,请前往邮箱激活。",
|
||||||
|
"resendVerification": "重新发送验证邮件",
|
||||||
|
"verificationSent": "验证邮件已发送,请查收。",
|
||||||
|
"verifyEmailTitle": "邮箱验证",
|
||||||
|
"verifyingEmail": "正在验证您的邮箱...",
|
||||||
|
"verifyEmailSuccess": "邮箱验证成功!您现在可以登录了。",
|
||||||
|
"verifyEmailFailed": "邮箱验证失败或链接已过期。",
|
||||||
|
"goToLogin": "前往登录",
|
||||||
"errorOccurred": "发生了一个错误",
|
"errorOccurred": "发生了一个错误",
|
||||||
"mcpConfig": "MCP 配置",
|
"mcpConfig": "MCP 配置",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export function Login() {
|
|||||||
const [isLogin, setIsLogin] = useState(true);
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [isInactiveError, setIsInactiveError] = useState(false);
|
||||||
|
const [resendStatus, setResendStatus] = useState("");
|
||||||
|
const [isResending, setIsResending] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
|
|
||||||
@@ -28,9 +31,26 @@ export function Login() {
|
|||||||
password: "",
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
|
setIsInactiveError(false);
|
||||||
|
setResendStatus("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -66,10 +86,17 @@ export function Login() {
|
|||||||
|
|
||||||
// Auto login after successful registration
|
// Auto login after successful registration
|
||||||
setIsLogin(true);
|
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) {
|
} catch (err: any) {
|
||||||
|
if (err.message && err.message.toLowerCase().includes("inactive user")) {
|
||||||
|
setError(t("inactiveUserError"));
|
||||||
|
setIsInactiveError(true);
|
||||||
|
} else {
|
||||||
setError(err.message || t("errorOccurred"));
|
setError(err.message || t("errorOccurred"));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -108,8 +135,27 @@ export function Login() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{error && (
|
{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"}`}>
|
<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"}`}>
|
||||||
{error}
|
<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>
|
</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