mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-28 02:31:53 +08:00
feat: add password reset functionality with email templates
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
|
||||
from .routes import webdav
|
||||
from .routes import plugins
|
||||
|
||||
@@ -22,3 +22,4 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(offline_downloads.router)
|
||||
app.include_router(email.router)
|
||||
|
||||
@@ -10,6 +10,9 @@ from services.auth import (
|
||||
Token,
|
||||
get_current_active_user,
|
||||
User,
|
||||
request_password_reset,
|
||||
verify_password_reset_token,
|
||||
reset_password_with_token,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from datetime import timedelta
|
||||
@@ -83,6 +86,15 @@ class UpdateMeRequest(BaseModel):
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.put("/me", summary="更新当前登录用户信息")
|
||||
async def update_me(
|
||||
payload: UpdateMeRequest,
|
||||
@@ -120,3 +132,24 @@ async def update_me(
|
||||
"full_name": db_user.full_name,
|
||||
"gravatar_url": gravatar_url,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/request", summary="请求密码重置邮件")
|
||||
async def password_reset_request_endpoint(payload: PasswordResetRequest):
|
||||
await request_password_reset(payload.email)
|
||||
return success(msg="如果邮箱存在,将发送重置邮件")
|
||||
|
||||
|
||||
@router.get("/password-reset/verify", summary="校验密码重置令牌")
|
||||
async def password_reset_verify(token: str):
|
||||
user = await verify_password_reset_token(token)
|
||||
return success({
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm", summary="使用令牌重置密码")
|
||||
async def password_reset_confirm(payload: PasswordResetConfirm):
|
||||
await reset_password_with_token(payload.token, payload.password)
|
||||
return success(msg="密码已重置")
|
||||
|
||||
92
api/routes/email.py
Normal file
92
api/routes/email.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from services.auth import User, get_current_active_user
|
||||
from services.email import EmailService, EmailTemplateRenderer
|
||||
from schemas.email import EmailTestRequest, EmailTemplateUpdate, EmailTemplatePreviewPayload
|
||||
from api.response import success
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/email",
|
||||
tags=["email"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def trigger_test_email(
|
||||
payload: EmailTestRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
task = await EmailService.enqueue_email(
|
||||
recipients=[str(payload.to)],
|
||||
subject=payload.subject,
|
||||
template=payload.template,
|
||||
context=payload.context,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Triggered email test",
|
||||
details={"task_id": task.id, "template": payload.template, "to": str(payload.to)},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"task_id": task.id})
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_email_templates(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
templates = await EmailTemplateRenderer.list_templates()
|
||||
return success({"templates": templates})
|
||||
|
||||
|
||||
@router.get("/templates/{name}")
|
||||
async def get_email_template(
|
||||
name: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
content = await EmailTemplateRenderer.load(name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return success({"name": name, "content": content})
|
||||
|
||||
|
||||
@router.post("/templates/{name}")
|
||||
async def update_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplateUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
await EmailTemplateRenderer.save(name, payload.content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Updated email template",
|
||||
details={"template": name},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"name": name})
|
||||
|
||||
|
||||
@router.post("/templates/{name}/preview")
|
||||
async def preview_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplatePreviewPayload,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
html = await EmailTemplateRenderer.render(name, payload.context)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return success({"html": html})
|
||||
@@ -22,4 +22,5 @@ dependencies = [
|
||||
"uvicorn>=0.37.0",
|
||||
"pymilvus[milvus-lite]>=2.6.2",
|
||||
"paramiko>=4.0.0",
|
||||
"pydantic[email]>=2.11.7",
|
||||
]
|
||||
|
||||
18
schemas/email.py
Normal file
18
schemas/email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class EmailTestRequest(BaseModel):
|
||||
to: EmailStr
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(default="test", min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class EmailTemplatePreviewPayload(BaseModel):
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
160
services/auth.py
160
services/auth.py
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
@@ -10,9 +13,78 @@ from pydantic import BaseModel
|
||||
|
||||
from models.database import UserAccount
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 365
|
||||
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 10
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasswordResetEntry:
|
||||
user_id: int
|
||||
email: str
|
||||
username: str
|
||||
expires_at: datetime
|
||||
used: bool = False
|
||||
|
||||
|
||||
class PasswordResetStore:
|
||||
_tokens: dict[str, PasswordResetEntry] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def _cleanup(cls):
|
||||
now = _now()
|
||||
for token, record in list(cls._tokens.items()):
|
||||
if record.used or record.expires_at < now:
|
||||
cls._tokens.pop(token, None)
|
||||
|
||||
@classmethod
|
||||
async def create(cls, user: UserAccount) -> str:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user.id:
|
||||
cls._tokens.pop(key, None)
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = _now() + timedelta(minutes=PASSWORD_RESET_TOKEN_EXPIRE_MINUTES)
|
||||
cls._tokens[token] = PasswordResetEntry(
|
||||
user_id=user.id,
|
||||
email=user.email or "",
|
||||
username=user.username,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
async def get(cls, token: str) -> PasswordResetEntry | None:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
record = cls._tokens.get(token)
|
||||
if not record or record.used:
|
||||
return None
|
||||
return record
|
||||
|
||||
@classmethod
|
||||
async def mark_used(cls, token: str) -> None:
|
||||
async with cls._lock:
|
||||
record = cls._tokens.get(token)
|
||||
if record:
|
||||
record.used = True
|
||||
cls._cleanup()
|
||||
|
||||
@classmethod
|
||||
async def invalidate_user(cls, user_id: int, except_token: str | None = None) -> None:
|
||||
async with cls._lock:
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user_id and key != except_token:
|
||||
cls._tokens.pop(key, None)
|
||||
cls._cleanup()
|
||||
|
||||
|
||||
async def get_secret_key():
|
||||
@@ -132,6 +204,94 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def _normalize_email(email: str | None) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
|
||||
async def _send_password_reset_email(user: UserAccount, token: str) -> None:
|
||||
from services.email import EmailService
|
||||
|
||||
app_domain = await ConfigCenter.get("APP_DOMAIN", None)
|
||||
base_url = (app_domain or "http://localhost:5173").rstrip("/")
|
||||
reset_link = f"{base_url}/reset-password?token={token}"
|
||||
await EmailService.enqueue_email(
|
||||
recipients=[user.email],
|
||||
subject="Foxel 密码重置",
|
||||
template="password_reset",
|
||||
context={
|
||||
"username": user.username,
|
||||
"reset_link": reset_link,
|
||||
"expire_minutes": PASSWORD_RESET_TOKEN_EXPIRE_MINUTES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def request_password_reset(email: str) -> bool:
|
||||
normalized = _normalize_email(email)
|
||||
if not normalized:
|
||||
return False
|
||||
user = await UserAccount.get_or_none(email=normalized)
|
||||
if not user or not user.email:
|
||||
return False
|
||||
|
||||
token = await PasswordResetStore.create(user)
|
||||
try:
|
||||
await _send_password_reset_email(user, token)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.error(
|
||||
"auth",
|
||||
f"Failed to enqueue password reset email: {exc}",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="邮件发送失败") from exc
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset requested",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def verify_password_reset_token(token: str) -> UserAccount:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
return user
|
||||
|
||||
|
||||
async def reset_password_with_token(token: str, new_password: str) -> None:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
await user.save(update_fields=["hashed_password"])
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset via email",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
201
services/email.py
Normal file
201
services/email.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ValidationError
|
||||
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
class EmailSecurity(str, Enum):
|
||||
NONE = "none"
|
||||
SSL = "ssl"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
class EmailConfig(BaseModel):
|
||||
host: str
|
||||
port: int = Field(..., gt=0)
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
sender_email: EmailStr
|
||||
sender_name: Optional[str] = None
|
||||
security: EmailSecurity = EmailSecurity.NONE
|
||||
timeout: float = Field(default=30.0, gt=0.0)
|
||||
|
||||
|
||||
class EmailSendPayload(BaseModel):
|
||||
recipients: List[EmailStr] = Field(..., min_length=1)
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(..., min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateRenderer:
|
||||
ROOT = Path("templates/email")
|
||||
|
||||
@classmethod
|
||||
def _resolve_path(cls, template_name: str) -> Path:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_\-]+", template_name):
|
||||
raise ValueError("Invalid template name")
|
||||
return cls.ROOT / f"{template_name}.html"
|
||||
|
||||
@classmethod
|
||||
async def list_templates(cls) -> list[str]:
|
||||
cls.ROOT.mkdir(parents=True, exist_ok=True)
|
||||
return sorted(
|
||||
path.stem
|
||||
for path in cls.ROOT.glob("*.html")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, template_name: str) -> str:
|
||||
path = cls._resolve_path(template_name)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Email template '{template_name}' not found")
|
||||
return await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def save(cls, template_name: str, content: str) -> None:
|
||||
path = cls._resolve_path(template_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(path.write_text, content, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def render(cls, template_name: str, context: Dict[str, Any]) -> str:
|
||||
raw = await cls.load(template_name)
|
||||
context = {k: str(v) for k, v in (context or {}).items()}
|
||||
return Template(raw).safe_substitute(context)
|
||||
|
||||
|
||||
class EmailService:
|
||||
CONFIG_KEY = "EMAIL_CONFIG"
|
||||
|
||||
@classmethod
|
||||
async def _load_config(cls) -> EmailConfig:
|
||||
raw_config = await ConfigCenter.get(cls.CONFIG_KEY)
|
||||
if raw_config is None:
|
||||
raise ValueError("Email configuration not found")
|
||||
|
||||
if isinstance(raw_config, str):
|
||||
raw_config = raw_config.strip()
|
||||
data: Any = json.loads(raw_config) if raw_config else {}
|
||||
elif isinstance(raw_config, dict):
|
||||
data = raw_config
|
||||
else:
|
||||
raise ValueError("Invalid email configuration format")
|
||||
|
||||
try:
|
||||
return EmailConfig(**data)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid email configuration: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _html_to_text(html: str) -> str:
|
||||
stripped = re.sub(r"<[^>]+>", " ", html)
|
||||
return " ".join(stripped.split())
|
||||
|
||||
@classmethod
|
||||
async def _deliver(cls, config: EmailConfig, payload: EmailSendPayload, html_body: str):
|
||||
message = EmailMessage()
|
||||
message["Subject"] = payload.subject
|
||||
message["From"] = formataddr((config.sender_name or str(config.sender_email), str(config.sender_email)))
|
||||
message["To"] = ", ".join([str(addr) for addr in payload.recipients])
|
||||
|
||||
plain_body = cls._html_to_text(html_body)
|
||||
message.set_content(plain_body or html_body)
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
await asyncio.to_thread(cls._deliver_sync, config, message)
|
||||
|
||||
@staticmethod
|
||||
def _deliver_sync(config: EmailConfig, message: EmailMessage):
|
||||
if config.security == EmailSecurity.SSL:
|
||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(config.host, config.port, timeout=config.timeout)
|
||||
else:
|
||||
smtp = smtplib.SMTP(config.host, config.port, timeout=config.timeout)
|
||||
|
||||
try:
|
||||
if config.security == EmailSecurity.STARTTLS:
|
||||
smtp.starttls()
|
||||
if config.username and config.password:
|
||||
smtp.login(config.username, config.password)
|
||||
smtp.send_message(message)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
async def enqueue_email(
|
||||
cls,
|
||||
recipients: List[str],
|
||||
subject: str,
|
||||
template: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
template=template,
|
||||
context=context or {},
|
||||
)
|
||||
|
||||
task = await task_queue_service.add_task(
|
||||
"send_email",
|
||||
payload.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(stage="queued", percent=0.0, detail="Waiting to send"),
|
||||
)
|
||||
await LogService.action(
|
||||
"email_service",
|
||||
"Email task enqueued",
|
||||
details={"task_id": task.id, "subject": subject, "template": template},
|
||||
)
|
||||
return task
|
||||
|
||||
@classmethod
|
||||
async def send_from_task(cls, task_id: str, data: Dict[str, Any]):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(**data)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="preparing", percent=10.0, detail="Rendering template"),
|
||||
)
|
||||
|
||||
config = await cls._load_config()
|
||||
html_body = await EmailTemplateRenderer.render(payload.template, payload.context)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="sending", percent=60.0, detail="Sending message"),
|
||||
)
|
||||
|
||||
await cls._deliver(config, payload, html_body)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="completed", percent=100.0, detail="Email sent"),
|
||||
)
|
||||
await LogService.info(
|
||||
"email_service",
|
||||
"Email sent",
|
||||
details={"task_id": task_id, "subject": payload.subject},
|
||||
)
|
||||
@@ -130,6 +130,10 @@ class TaskQueueService:
|
||||
|
||||
result = await run_cross_mount_transfer_task(task)
|
||||
task.result = result
|
||||
elif task.name == "send_email":
|
||||
from services.email import EmailService
|
||||
await EmailService.send_from_task(task.id, task.task_info)
|
||||
task.result = "Email sent"
|
||||
else:
|
||||
raise ValueError(f"Unknown task name: {task.name}")
|
||||
|
||||
|
||||
102
templates/email/password_reset.html
Normal file
102
templates/email/password_reset.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 密码重置</title>
|
||||
<style>
|
||||
body {
|
||||
background: #f4f7fb;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(120deg, #4f46e5, #7c3aed);
|
||||
padding: 32px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
}
|
||||
.content p {
|
||||
margin: 16px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cta {
|
||||
display: block;
|
||||
margin: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: linear-gradient(120deg, #6366f1, #8b5cf6);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.32);
|
||||
}
|
||||
.info-box {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid rgba(107, 114, 128, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.footer a {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>重置你的 Foxel 密码</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>你好,${username}。</p>
|
||||
<p>我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:</p>
|
||||
<div class="cta">
|
||||
<a href="${reset_link}" target="_blank" rel="noopener">重置密码</a>
|
||||
</div>
|
||||
<p>如果按钮无法点击,你也可以复制下面的链接到浏览器打开:</p>
|
||||
<div class="info-box">
|
||||
<div style="word-break: break-all;">${reset_link}</div>
|
||||
</div>
|
||||
<p>该链接在 ${expire_minutes} 分钟内有效。若你未发起此请求,请忽略本邮件,你的密码不会发生变化。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div>此邮件由 Foxel 系统自动发送,请勿直接回复。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
97
templates/email/test.html
Normal file
97
templates/email/test.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 邮件配置测试</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
background: linear-gradient(135deg, #eef2ff, #e0f2fe);
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #0f172a;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.banner {
|
||||
background: linear-gradient(120deg, #1d4ed8, #6366f1);
|
||||
padding: 36px;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.banner h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cta-box {
|
||||
margin-top: 32px;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.08));
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.cta-box strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="banner">
|
||||
<h1>Foxel 邮件服务已连通</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Mail Delivery Test</div>
|
||||
<p>你好,${username}!</p>
|
||||
<p>
|
||||
这是一封来自 <strong>Foxel</strong> 的测试邮件。如果你能够正常阅读到这段内容,说明系统已经成功与配置的邮箱服务建立连接。
|
||||
</p>
|
||||
<div class="cta-box">
|
||||
<strong>接下来可以做什么?</strong>
|
||||
<ul style="margin: 0; padding-left: 18px; line-height: 1.7;">
|
||||
<li>继续完善系统通知、密码重置等业务功能</li>
|
||||
<li>在后台页面中自定义更精美的邮件模板</li>
|
||||
<li>保持发送凭据安全,避免泄露</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
本邮件由 Foxel 系统自动发送,请勿直接回复。如非本人操作,请忽略此邮件。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
29
uv.lock
generated
29
uv.lock
generated
@@ -373,6 +373,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.116.1"
|
||||
@@ -399,6 +421,7 @@ dependencies = [
|
||||
{ name = "paramiko" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymilvus", extra = ["milvus-lite"] },
|
||||
{ name = "pysocks" },
|
||||
@@ -420,6 +443,7 @@ requires-dist = [
|
||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||
{ name = "pillow", specifier = ">=11.3.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "pymilvus", extras = ["milvus-lite"], specifier = ">=2.6.2" },
|
||||
{ name = "pysocks", specifier = ">=1.7.1" },
|
||||
@@ -1050,6 +1074,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
|
||||
@@ -32,6 +32,15 @@ export interface UpdateMePayload {
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmPayload {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
||||
return request('/auth/register', {
|
||||
@@ -68,4 +77,19 @@ export const authApi = {
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
|
||||
return await request('/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
verifyPasswordResetToken: async (token: string) => {
|
||||
return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
|
||||
},
|
||||
confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
|
||||
return await request('/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
41
web/src/api/email.ts
Normal file
41
web/src/api/email.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from './client';
|
||||
|
||||
export interface EmailTestPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
template?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function sendTestEmail(payload: EmailTestPayload) {
|
||||
return request<{ task_id: string }>('/email/test', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
template: 'test',
|
||||
context: {},
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmailTemplates() {
|
||||
return request<{ templates: string[] }>('/email/templates');
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(name: string) {
|
||||
return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(name: string, content: string) {
|
||||
return request(`/email/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
json: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(name: string, context: Record<string, unknown>) {
|
||||
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
|
||||
method: 'POST',
|
||||
json: { context },
|
||||
});
|
||||
}
|
||||
@@ -284,6 +284,7 @@ export const en = {
|
||||
'Custom CSS': 'Custom CSS',
|
||||
'Save': 'Save',
|
||||
'App Settings': 'App Settings',
|
||||
'Email Settings': 'Email Settings',
|
||||
'AI Settings': 'AI Settings',
|
||||
'Vision Model': 'Vision Model',
|
||||
'Embedding Model': 'Embedding Model',
|
||||
@@ -331,6 +332,62 @@ export const en = {
|
||||
'Favicon URL': 'Favicon URL',
|
||||
'App Domain': 'App Domain',
|
||||
'File Domain': 'File Domain',
|
||||
'SMTP Settings': 'SMTP Settings',
|
||||
'SMTP Host': 'SMTP Host',
|
||||
'Please input SMTP host': 'Please input SMTP host',
|
||||
'SMTP Port': 'SMTP Port',
|
||||
'Please input SMTP port': 'Please input SMTP port',
|
||||
'Security': 'Security',
|
||||
'None': 'None',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': 'Timeout (seconds)',
|
||||
'Sender': 'Sender',
|
||||
'Sender Name': 'Sender Name',
|
||||
'Sender Email': 'Sender Email',
|
||||
'Please input sender email': 'Please input sender email',
|
||||
'Authentication': 'Authentication',
|
||||
'SMTP Username': 'SMTP Username',
|
||||
'SMTP Password': 'SMTP Password',
|
||||
'Test Email': 'Test Email',
|
||||
'Current Configuration': 'Current Configuration',
|
||||
'Available variables': 'Available variables',
|
||||
'Not set': 'Not set',
|
||||
'Password Reset Template': 'Password Reset Template',
|
||||
'Live Preview': 'Live Preview',
|
||||
'Foxel Mail Test': 'Foxel Mail Test',
|
||||
'Recipient Address': 'Recipient Address',
|
||||
'Please input recipient email': 'Please input recipient email',
|
||||
'Test Subject': 'Test Subject',
|
||||
'Test User Name': 'Test User Name',
|
||||
'Optional': 'Optional',
|
||||
'Send Test Email': 'Send Test Email',
|
||||
'Please complete all required fields': 'Please complete all required fields',
|
||||
'SMTP port must be a positive number': 'SMTP port must be a positive number',
|
||||
'Test email queued (task {{taskId}})': 'Test email queued (task {{taskId}})',
|
||||
'Test email failed': 'Test email failed',
|
||||
|
||||
// Auth reset
|
||||
'Forgot Password?': 'Forgot password?',
|
||||
'Reset Your Password': 'Reset Your Password',
|
||||
'Enter the email linked to your account and we will send a reset link.': 'Enter the email linked to your account and we will send a reset link.',
|
||||
'If the email exists, a reset link has been sent.': 'If the email exists, a reset link has been sent.',
|
||||
'Send Reset Link': 'Send Reset Link',
|
||||
'Resend Link': 'Resend Link',
|
||||
'Back to login': 'Back to login',
|
||||
'Request failed': 'Request failed',
|
||||
'Reset link is invalid': 'Reset link is invalid',
|
||||
'Reset link is invalid or expired': 'Reset link is invalid or expired',
|
||||
'Reset failed': 'Reset failed',
|
||||
'Try again': 'Try again',
|
||||
'Set a new password': 'Set a new password',
|
||||
'Please enter new password': 'Please enter new password',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm new password': 'Please confirm new password',
|
||||
'Update Password': 'Update Password',
|
||||
'Passwords do not match': 'Passwords do not match',
|
||||
'Password updated, please login again.': 'Password updated, please login again.',
|
||||
'Failed to reset password': 'Failed to reset password',
|
||||
'Vision API URL': 'Vision API URL',
|
||||
'Vision API Key': 'Vision API Key',
|
||||
'Embedding API URL': 'Embedding API URL',
|
||||
@@ -599,7 +656,6 @@ export const en = {
|
||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||
'Username': 'Username',
|
||||
'Please input a valid email!': 'Please input a valid email!',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm your password!': 'Please confirm your password!',
|
||||
'Passwords do not match!': 'Passwords do not match!',
|
||||
'System Initialization': 'System Initialization',
|
||||
|
||||
@@ -63,12 +63,32 @@ export const zh = {
|
||||
'Sign In': '登录',
|
||||
'Please enter username and password': '请输入用户名与密码',
|
||||
'Login failed': '登录失败',
|
||||
'Forgot Password?': '忘记密码?',
|
||||
'Your next-generation file manager': '您的下一代文件管理系统',
|
||||
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
||||
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
||||
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
||||
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
||||
'Join our community:': '加入我们的社区:',
|
||||
'Reset Your Password': '重置你的密码',
|
||||
'Enter the email linked to your account and we will send a reset link.': '请输入你账户绑定的邮箱,我们会发送重置链接。',
|
||||
'If the email exists, a reset link has been sent.': '如果邮箱存在,我们已发送重置链接。',
|
||||
'Send Reset Link': '发送重置链接',
|
||||
'Resend Link': '重新发送链接',
|
||||
'Back to login': '返回登录',
|
||||
'Request failed': '请求失败',
|
||||
'Reset link is invalid': '重置链接无效',
|
||||
'Reset link is invalid or expired': '重置链接无效或已过期',
|
||||
'Reset failed': '重置失败',
|
||||
'Try again': '重试',
|
||||
'Set a new password': '设置新密码',
|
||||
'Please enter new password': '请输入新密码',
|
||||
'Confirm Password': '确认新密码',
|
||||
'Please confirm new password': '请确认新密码',
|
||||
'Update Password': '更新密码',
|
||||
'Passwords do not match': '两次输入的密码不一致',
|
||||
'Password updated, please login again.': '密码已更新,请重新登录。',
|
||||
'Failed to reset password': '密码重置失败',
|
||||
|
||||
// Share page
|
||||
'Refresh': '刷新',
|
||||
@@ -285,6 +305,7 @@ export const zh = {
|
||||
'Custom CSS': '自定义 CSS',
|
||||
'Save': '保存',
|
||||
'App Settings': '应用设置',
|
||||
'Email Settings': '邮箱设置',
|
||||
'AI Settings': 'AI设置',
|
||||
'Choose Template': '选择模板',
|
||||
'Configure Provider': '配置提供商',
|
||||
@@ -336,6 +357,44 @@ export const zh = {
|
||||
'Favicon URL': 'Favicon 地址',
|
||||
'App Domain': '应用域名',
|
||||
'File Domain': '文件域名',
|
||||
'SMTP Settings': 'SMTP 配置',
|
||||
'SMTP Host': 'SMTP 服务器',
|
||||
'Please input SMTP host': '请输入 SMTP 服务器',
|
||||
'SMTP Port': 'SMTP 端口',
|
||||
'Please input SMTP port': '请输入 SMTP 端口',
|
||||
'Security': '安全协议',
|
||||
'None': '无',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': '超时时间(秒)',
|
||||
'Sender': '发件人',
|
||||
'Sender Name': '发件人名称',
|
||||
'Sender Email': '发件人邮箱',
|
||||
'Please input sender email': '请输入发件人邮箱',
|
||||
'Authentication': '身份认证',
|
||||
'SMTP Username': 'SMTP 用户名',
|
||||
'SMTP Password': 'SMTP 密码',
|
||||
'Test Email': '测试发信',
|
||||
'Current Configuration': '当前配置摘要',
|
||||
'Available variables': '可用变量',
|
||||
'Not set': '未设置',
|
||||
'Password Reset Template': '密码重置模板',
|
||||
'Live Preview': '实时预览',
|
||||
'Template saved': '模板已保存',
|
||||
'Failed to save template': '模板保存失败',
|
||||
'Failed to load template': '模板加载失败',
|
||||
'Preview failed': '预览失败',
|
||||
'Foxel Mail Test': 'Foxel 邮件测试',
|
||||
'Recipient Address': '收件人地址',
|
||||
'Please input recipient email': '请输入收件人邮箱',
|
||||
'Test Subject': '测试邮件标题',
|
||||
'Test User Name': '测试用户名',
|
||||
'Optional': '可选',
|
||||
'Send Test Email': '发送测试邮件',
|
||||
'Please complete all required fields': '请填写所有必填项',
|
||||
'SMTP port must be a positive number': 'SMTP 端口必须为正数',
|
||||
'Test email queued (task {{taskId}})': '测试邮件已入队(任务 {{taskId}})',
|
||||
'Test email failed': '测试邮件发送失败',
|
||||
'Vision API URL': '视觉模型 API 地址',
|
||||
'Vision API Key': '视觉模型 API Key',
|
||||
'Embedding API URL': '嵌入模型 API 地址',
|
||||
@@ -612,7 +671,6 @@ export const zh = {
|
||||
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
|
||||
'Username': '用户名',
|
||||
'Please input a valid email!': '请输入有效的邮箱地址!',
|
||||
'Confirm Password': '确认密码',
|
||||
'Please confirm your password!': '请确认您的密码!',
|
||||
'Passwords do not match!': '两次输入的密码不一致!',
|
||||
'System Initialization': '系统初始化',
|
||||
|
||||
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message } from 'antd';
|
||||
import { MailOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: { email: string }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.requestPasswordReset({ email: values.email });
|
||||
message.success(t('If the email exists, a reset link has been sent.'));
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Request failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
|
||||
border: '1px solid rgba(99,102,241,0.12)',
|
||||
}}
|
||||
styles={{ body: { padding: '40px 36px' } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
}}>
|
||||
<MailOutlined />
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Reset Your Password')}</Title>
|
||||
<Text type="secondary">
|
||||
{t('Enter the email linked to your account and we will send a reset link.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={t('Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="me@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 32 }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block>
|
||||
{sent ? t('Resend Link') : t('Send Reset Link')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,12 @@ export default function LoginPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 8, textAlign: 'right' }}>
|
||||
<Button type="link" onClick={() => navigate('/forgot-password')} style={{ padding: 0 }}>
|
||||
{t('Forgot Password?')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
146
web/src/pages/ResetPasswordPage.tsx
Normal file
146
web/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message, Result } from 'antd';
|
||||
import { LockOutlined, CheckCircleTwoTone } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const token = useMemo(() => new URLSearchParams(location.search).get('token') || '', [location.search]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t('Reset link is invalid'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
authApi.verifyPasswordResetToken(token)
|
||||
.then(setUserInfo)
|
||||
.catch((err) => {
|
||||
setError(err?.message || t('Reset link is invalid or expired'));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [token, t]);
|
||||
|
||||
const handleSubmit = async (values: { password: string; confirm: string }) => {
|
||||
if (values.password !== values.confirm) {
|
||||
message.error(t('Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.confirmPasswordReset({ token, password: values.password });
|
||||
setSuccess(true);
|
||||
message.success(t('Password updated, please login again.'));
|
||||
setTimeout(() => navigate('/login'), 1500);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to reset password'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Result
|
||||
status="error"
|
||||
title={t('Reset failed')}
|
||||
subTitle={error}
|
||||
extra={[
|
||||
<Button type="primary" key="back" onClick={() => navigate('/forgot-password')}>
|
||||
{t('Try again')}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(99,102,241,0.14)',
|
||||
boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
|
||||
}}
|
||||
bodyStyle={{ padding: '40px 36px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: success ? '#ecfdf5' : 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: success ? '#047857' : '#fff',
|
||||
fontSize: success ? 32 : 28,
|
||||
}}>
|
||||
{success ? <CheckCircleTwoTone twoToneColor="#22c55e" /> : <LockOutlined />}
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Set a new password')}</Title>
|
||||
{userInfo && <Text type="secondary">{userInfo.email}</Text>}
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('New Password')}
|
||||
rules={[{ required: true, message: t('Please enter new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label={t('Confirm Password')}
|
||||
rules={[{ required: true, message: t('Please confirm new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
{t('Update Password')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { getAllConfig, setConfig } from '../../api/config';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import '../../styles/settings-tabs.css';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -10,10 +10,11 @@ import AppearanceSettingsTab from './components/AppearanceSettingsTab';
|
||||
import AppSettingsTab from './components/AppSettingsTab';
|
||||
import AiSettingsTab from './components/AiSettingsTab';
|
||||
import VectorDbSettingsTab from './components/VectorDbSettingsTab';
|
||||
import EmailSettingsTab from './components/EmailSettingsTab';
|
||||
|
||||
type TabKey = 'appearance' | 'app' | 'ai' | 'vector-db';
|
||||
type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db';
|
||||
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'ai', 'vector-db'];
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db'];
|
||||
const DEFAULT_TAB: TabKey = 'appearance';
|
||||
|
||||
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key);
|
||||
@@ -150,6 +151,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: (
|
||||
<span>
|
||||
<MailOutlined style={{ marginRight: 8 }} />
|
||||
{t('Email Settings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<EmailSettingsTab
|
||||
config={config}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: (
|
||||
|
||||
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Tag,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { HighlightOutlined, EyeOutlined, SaveOutlined, SendOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
sendTestEmail,
|
||||
getEmailTemplate,
|
||||
updateEmailTemplate,
|
||||
previewEmailTemplate,
|
||||
} from '../../../api/email';
|
||||
|
||||
interface EmailSettingsTabProps {
|
||||
config: Record<string, string>;
|
||||
loading: boolean;
|
||||
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EmailFormValues {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
sender_name?: string;
|
||||
sender_email: string;
|
||||
security: 'none' | 'ssl' | 'starttls';
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface TestFormValues {
|
||||
to: string;
|
||||
subject: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface PreviewContext extends Record<string, unknown> {
|
||||
username: string;
|
||||
reset_link: string;
|
||||
expire_minutes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: EmailFormValues = {
|
||||
host: '',
|
||||
port: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
sender_name: '',
|
||||
sender_email: '',
|
||||
security: 'ssl',
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
const TEMPLATE_NAME = 'password_reset';
|
||||
|
||||
function parseEmailConfig(raw?: string | null): EmailFormValues {
|
||||
if (!raw) return { ...DEFAULT_FORM };
|
||||
try {
|
||||
const data = JSON.parse(raw) as Partial<EmailFormValues>;
|
||||
return {
|
||||
...DEFAULT_FORM,
|
||||
...data,
|
||||
port: Number(data?.port ?? DEFAULT_FORM.port),
|
||||
timeout: data?.timeout !== undefined ? Number(data.timeout) : DEFAULT_FORM.timeout,
|
||||
security: (data?.security ?? DEFAULT_FORM.security) as EmailFormValues['security'],
|
||||
};
|
||||
} catch (_err) {
|
||||
return { ...DEFAULT_FORM };
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailSettingsTab({ config, loading, onSave }: EmailSettingsTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [testForm] = Form.useForm<TestFormValues>();
|
||||
const [previewForm] = Form.useForm<PreviewContext>();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [template, setTemplate] = useState<string>('');
|
||||
const [templateLoading, setTemplateLoading] = useState(true);
|
||||
const [templateSaving, setTemplateSaving] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
const initialValues = useMemo(() => parseEmailConfig(config?.EMAIL_CONFIG), [config]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const parsed = parseEmailConfig(config?.EMAIL_CONFIG);
|
||||
return [
|
||||
{ label: t('SMTP Host'), value: parsed.host || '-' },
|
||||
{ label: t('SMTP Port'), value: parsed.port || '-' },
|
||||
{ label: t('Security'), value: parsed.security.toUpperCase() },
|
||||
{ label: t('Sender Email'), value: parsed.sender_email || '-' },
|
||||
{ label: t('Sender Name'), value: parsed.sender_name || t('Not set') },
|
||||
{ label: t('Timeout (seconds)'), value: parsed.timeout || '-' },
|
||||
];
|
||||
}, [config, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setTemplateLoading(true);
|
||||
getEmailTemplate(TEMPLATE_NAME)
|
||||
.then((res) => setTemplate(res.content))
|
||||
.catch((err) => {
|
||||
message.error(err?.message || t('Failed to load template'));
|
||||
})
|
||||
.finally(() => setTemplateLoading(false));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
previewForm.setFieldsValue({
|
||||
username: 'Foxel 用户',
|
||||
reset_link: 'https://foxel.cc/reset-password?token=demo',
|
||||
expire_minutes: 10,
|
||||
});
|
||||
}, [previewForm]);
|
||||
|
||||
const handleSaveConfig = async (values: EmailFormValues) => {
|
||||
if (!values.host || !values.port || !values.sender_email) {
|
||||
message.error(t('Please complete all required fields'));
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
host: values.host.trim(),
|
||||
port: Number(values.port),
|
||||
sender_email: values.sender_email.trim(),
|
||||
security: values.security,
|
||||
};
|
||||
if (!Number.isFinite(payload.port as number) || (payload.port as number) <= 0) {
|
||||
message.error(t('SMTP port must be a positive number'));
|
||||
return;
|
||||
}
|
||||
if (values.username?.trim()) {
|
||||
payload.username = values.username.trim();
|
||||
}
|
||||
if (values.password?.length) {
|
||||
payload.password = values.password;
|
||||
}
|
||||
if (values.sender_name?.trim()) {
|
||||
payload.sender_name = values.sender_name.trim();
|
||||
}
|
||||
if (values.timeout !== undefined && values.timeout !== null) {
|
||||
const timeoutNumber = Number(values.timeout);
|
||||
if (Number.isFinite(timeoutNumber) && timeoutNumber > 0) {
|
||||
payload.timeout = timeoutNumber;
|
||||
}
|
||||
}
|
||||
await onSave({ EMAIL_CONFIG: JSON.stringify(payload) });
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
const values = await testForm.validateFields();
|
||||
setTesting(true);
|
||||
const response = await sendTestEmail({
|
||||
to: values.to,
|
||||
subject: values.subject,
|
||||
template: 'test',
|
||||
context: { username: values.username || values.to },
|
||||
});
|
||||
message.success(t('Test email queued (task {{taskId}})', { taskId: response.task_id }));
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
return;
|
||||
}
|
||||
message.error(err?.message || t('Test email failed'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = async () => {
|
||||
try {
|
||||
const values = await previewForm.validateFields();
|
||||
setPreviewing(true);
|
||||
const res = await previewEmailTemplate(TEMPLATE_NAME, values);
|
||||
setPreviewHtml(res.html);
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) return;
|
||||
message.error(err?.message || t('Preview failed'));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await updateEmailTemplate(TEMPLATE_NAME, template);
|
||||
message.success(t('Template saved'));
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to save template'));
|
||||
} finally {
|
||||
setTemplateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={15}>
|
||||
<Card
|
||||
title={t('SMTP Settings')}
|
||||
extra={<InfoCircleOutlined style={{ color: 'var(--ant-color-primary)' }} />}
|
||||
bodyStyle={{ paddingBottom: 12 }}
|
||||
>
|
||||
<Form<EmailFormValues>
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSaveConfig}
|
||||
key={'email-settings-' + (config?.EMAIL_CONFIG ?? '')}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
name="host"
|
||||
label={t('SMTP Host')}
|
||||
rules={[{ required: true, message: t('Please input SMTP host') }]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label={t('SMTP Port')}
|
||||
rules={[{ required: true, message: t('Please input SMTP port') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="security" label={t('Security')}>
|
||||
<Select
|
||||
size="large"
|
||||
options={[
|
||||
{ value: 'none', label: t('None') },
|
||||
{ value: 'ssl', label: 'SSL' },
|
||||
{ value: 'starttls', label: 'STARTTLS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="timeout" label={t('Timeout (seconds)')}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Form.Item name="sender_name" label={t('Sender Name')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sender_email"
|
||||
label={t('Sender Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input sender email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="username" label={t('SMTP Username')}>
|
||||
<Input size="large" autoComplete="username" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="password" label={t('SMTP Password')}>
|
||||
<Input.Password size="large" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item style={{ marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} icon={<SaveOutlined />} block>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={9}>
|
||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||
<Card title={t('Current Configuration')} bodyStyle={{ paddingBottom: 12 }}>
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
{summary.map(item => (
|
||||
<Descriptions.Item key={item.label} label={<TextLabel text={item.label} />}>
|
||||
<Typography.Text strong>{item.value}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card title={t('Test Email')} extra={<SendOutlined style={{ color: 'var(--ant-color-primary)' }} />}>
|
||||
<Form<TestFormValues>
|
||||
form={testForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
subject: t('Foxel Mail Test'),
|
||||
username: '',
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="to"
|
||||
label={t('Recipient Address')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label={t('Test Subject')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" label={t('Test User Name')}>
|
||||
<Input size="large" placeholder={t('Optional')} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleTest} loading={testing} block icon={<SendOutlined />}>
|
||||
{t('Send Test Email')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HighlightOutlined />
|
||||
{t('Password Reset Template')}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<EyeOutlined />} onClick={handlePreviewTemplate} loading={previewing}>
|
||||
{t('Preview')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveTemplate} loading={templateSaving}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
{templateLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
autoSize={{ minRows: 20 }}
|
||||
style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text type="secondary">{t('Available variables')}:</Typography.Text>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Tag color="blue">${'{username}'}</Tag>
|
||||
<Tag color="blue">${'{reset_link}'}</Tag>
|
||||
<Tag color="blue">${'{expire_minutes}'}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title={t('Preview Context')} size="small" style={{ marginBottom: 16 }}>
|
||||
<Form<PreviewContext> layout="vertical" form={previewForm}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="username"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reset_link"
|
||||
label="reset_link"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expire_minutes"
|
||||
label="expire_minutes"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title={t('Live Preview')} size="small" className="email-template-preview">
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid rgba(148,163,184,0.2)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
height: 360,
|
||||
background: '#f8fafc',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
title="email-preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
srcDoc={previewHtml || template}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function TextLabel({ text }: { text: string }) {
|
||||
return <Typography.Text type="secondary">{text}</Typography.Text>;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import LayoutShell from './LayoutShell.tsx';
|
||||
import LoginPage from '../pages/LoginPage.tsx';
|
||||
import SetupPage from '../pages/SetupPage.tsx';
|
||||
import PublicSharePage from '../pages/PublicSharePage';
|
||||
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
@@ -13,12 +15,16 @@ export const routes: RouteObject[] = [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||
{ path: '/setup', element: <SetupPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/reset-password', element: <ResetPasswordPage /> },
|
||||
];
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && location.pathname !== '/login' && location.pathname !== '/register') {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
const isPublic = publicPaths.some((p) => location.pathname.startsWith(p));
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
|
||||
Reference in New Issue
Block a user