feat(auth): require verified email for approval

This commit is contained in:
2026-05-06 20:57:54 +08:00
parent f2554c7e56
commit 6afc5817a7
26 changed files with 944 additions and 28 deletions
+15 -4
View File
@@ -1,6 +1,6 @@
from typing import List
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
@@ -10,7 +10,7 @@ from backend.schemas.email_settings import (
EmailNotificationSettingsResponse,
EmailNotificationSettingsUpdate,
)
from backend.schemas.user import UserResponse
from backend.schemas.user import AdminApprovalResponse, UserResponse
from backend.services.check_in_service import CheckInService
from backend.services.admin_service import AdminService
from backend.services.email_settings_service import EmailSettingsService
@@ -322,9 +322,14 @@ async def get_pending_users(
)
@router.post("/users/{user_id}/approve", response_model=dict, summary="审批通过用户")
@router.post(
"/users/{user_id}/approve",
response_model=AdminApprovalResponse,
summary="审批通过用户",
)
async def approve_user(
user_id: int,
payload: dict = Body(default_factory=dict),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
@@ -332,9 +337,15 @@ async def approve_user(
审批通过指定用户(需要管理员权限)
"""
try:
result = AdminService.approve_user(user_id, db)
result = AdminService.approve_user(
user_id,
db,
allow_unverified_email=bool(payload.get("allow_unverified_email", False)),
)
if not result["success"]:
if result.get("requires_override"):
return result
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["message"])
return result
+71 -2
View File
@@ -4,15 +4,19 @@ from sqlalchemy.orm import Session
from backend.models import get_db, User
from backend.schemas.user import (
AdminApprovalResponse,
UserCreate,
UserUpdate,
UserResponse,
TokenStatus,
UserUpdateProfile,
UserEmailUpdate,
UserEmailVerify,
)
from backend.schemas.task import TaskResponse
from backend.services.user_service import UserService
from backend.services.task_service import TaskService
from backend.services.admin_service import AdminService
from backend.dependencies import get_current_user, get_current_admin_user
from backend.exceptions import (
AuthorizationError,
@@ -69,6 +73,8 @@ async def get_current_user_info(current_user: User = Depends(get_current_user)):
"is_approved": current_user.is_approved,
"jwt_exp": current_user.jwt_exp,
"email": current_user.email,
"email_verified": current_user.email_verified,
"email_verified_at": current_user.email_verified_at,
"has_password": bool(current_user.password_hash),
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
@@ -85,10 +91,57 @@ async def get_user_status(current_user: User = Depends(get_current_user)):
"user_id": current_user.id,
"alias": current_user.alias,
"is_approved": current_user.is_approved,
"email": current_user.email,
"email_verified": current_user.email_verified,
"email_verified_at": current_user.email_verified_at.isoformat()
if current_user.email_verified_at
else None,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
}
@router.put("/me/email", response_model=UserResponse, summary="设置邮箱并发送验证码")
async def set_current_user_email(
email_data: UserEmailUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
设置当前用户邮箱并发送验证码(不要求账户已审批)。
"""
try:
return UserService.set_email_for_verification(current_user.id, str(email_data.email), db)
except ValueError as e:
raise ValidationError(str(e))
except EXPECTED_API_EXCEPTIONS:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"设置邮箱失败: {str(e)}"
)
@router.post("/me/email/verify", response_model=UserResponse, summary="验证当前用户邮箱")
async def verify_current_user_email(
verify_data: UserEmailVerify,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
校验当前用户邮箱验证码(不要求账户已审批)。
"""
try:
return UserService.verify_email_code(current_user.id, verify_data.code, db)
except ValueError as e:
raise ValidationError(str(e))
except EXPECTED_API_EXCEPTIONS:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"验证邮箱失败: {str(e)}"
)
@router.put("/me/profile", response_model=UserResponse, summary="更新个人信息")
async def update_current_user_profile(
profile_data: UserUpdateProfile,
@@ -211,7 +264,11 @@ async def get_user(
return user
@router.put("/{user_id}", response_model=UserResponse, summary="更新用户信息")
@router.put(
"/{user_id}",
response_model=UserResponse | AdminApprovalResponse,
summary="更新用户信息",
)
async def update_user(
user_id: int,
user_data: UserUpdate,
@@ -241,6 +298,19 @@ async def update_user(
# 保存更新前的审批状态 (先读取后转换为 Python bool)
old_approved_value = old_user.is_approved
was_approved_before = True if old_approved_value else False
is_admin = current_user.role == "admin"
update_data = user_data.model_dump(exclude_unset=True)
will_approve = (
is_admin and (not was_approved_before) and update_data.get("is_approved") is True
)
if will_approve:
approval_warning = AdminService.approval_warning_for_user(
old_user,
allow_unverified_email=user_data.allow_unverified_email,
next_email=str(update_data["email"]) if "email" in update_data else None,
)
if approval_warning and approval_warning.get("requires_override"):
return approval_warning
# 更新用户信息
user = UserService.update_user(user_id, user_data, db)
@@ -249,7 +319,6 @@ async def update_user(
new_approved_value = user.is_approved
is_approved_now = True if new_approved_value else False
is_admin = current_user.role == "admin"
needs_notification = is_admin and (not was_approved_before) and is_approved_now
if needs_notification:
@@ -17,6 +17,8 @@ def apply(conn: Connection) -> None:
smtp_use_ssl BOOLEAN NOT NULL DEFAULT 1,
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1,
warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
)
@@ -0,0 +1,60 @@
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.engine import Connection
def _table_columns(conn: Connection, table_name: str) -> set[str]:
rows = conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall()
return {str(row[1]) for row in rows}
def _add_column_if_missing(
conn: Connection, table_name: str, columns: set[str], column_name: str, ddl: str
) -> set[str]:
if column_name not in columns:
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {ddl}"))
conn.commit()
return _table_columns(conn, table_name)
return columns
def apply(conn: Connection) -> None:
user_columns = _table_columns(conn, "users")
user_columns = _add_column_if_missing(
conn,
"users",
user_columns,
"email_verified_at",
"email_verified_at DATETIME",
)
user_columns = _add_column_if_missing(
conn,
"users",
user_columns,
"email_verification_code_hash",
"email_verification_code_hash VARCHAR(200)",
)
_add_column_if_missing(
conn,
"users",
user_columns,
"email_verification_expires_at",
"email_verification_expires_at DATETIME",
)
settings_columns = _table_columns(conn, "email_notification_settings")
settings_columns = _add_column_if_missing(
conn,
"email_notification_settings",
settings_columns,
"require_admin_approval_for_registration",
"require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1",
)
_add_column_if_missing(
conn,
"email_notification_settings",
settings_columns,
"warn_unverified_email_before_approval",
"warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1",
)
+8
View File
@@ -13,6 +13,9 @@ from backend.migration_steps.email_notification_settings import (
apply as apply_email_notification_settings,
)
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
from backend.migration_steps.user_email_verification import (
apply as apply_user_email_verification,
)
from backend.models.database import engine as default_engine
logger = logging.getLogger(__name__)
@@ -94,6 +97,11 @@ MIGRATIONS: tuple[Migration, ...] = (
description="Add admin-managed email notification settings.",
apply=apply_email_notification_settings,
),
Migration(
id="2026050601_add_user_email_verification",
description="Add user email verification fields and registration approval policy flags.",
apply=apply_user_email_verification,
),
)
+6
View File
@@ -22,6 +22,12 @@ class EmailNotificationSettings(Base):
smtp_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notify_token_expiring: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notify_check_in_success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
require_admin_approval_for_registration: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
warn_unverified_email_before_approval: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
)
+14
View File
@@ -30,6 +30,15 @@ class User(Base):
email: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="用户邮箱(用于接收通知)"
)
email_verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, comment="邮箱验证时间"
)
email_verification_code_hash: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="邮箱验证码哈希"
)
email_verification_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, comment="邮箱验证码过期时间"
)
password_hash: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="密码哈希(bcrypt加密)"
)
@@ -91,3 +100,8 @@ class User(Base):
def is_admin(self) -> bool:
"""判断是否为管理员"""
return self.role == "admin"
@property
def email_verified(self) -> bool:
"""判断当前邮箱是否已验证"""
return bool(self.email and self.email_verified_at)
+6
View File
@@ -5,6 +5,9 @@ from backend.schemas.user import (
UserResponse,
UserWithToken,
TokenStatus,
UserEmailUpdate,
UserEmailVerify,
AdminApprovalResponse,
)
from backend.schemas.auth import (
QRCodeRequest,
@@ -49,6 +52,9 @@ __all__ = [
"UserResponse",
"UserWithToken",
"TokenStatus",
"UserEmailUpdate",
"UserEmailVerify",
"AdminApprovalResponse",
"QRCodeRequest",
"QRCodeResponse",
"QRCodeStatusResponse",
+6
View File
@@ -13,6 +13,12 @@ class EmailNotificationSettingsBase(BaseModel):
smtp_use_ssl: bool = Field(True, description="是否使用 SMTP SSL")
notify_token_expiring: bool = Field(True, description="是否通知 Token 即将过期")
notify_check_in_success: bool = Field(True, description="是否通知打卡成功")
require_admin_approval_for_registration: bool = Field(
True, description="新注册是否需要管理员审批"
)
warn_unverified_email_before_approval: bool = Field(
True, description="审批未验证邮箱用户时是否警告"
)
@field_validator("smtp_server", "smtp_sender_email", mode="before")
@classmethod
+25
View File
@@ -29,6 +29,7 @@ class UserUpdate(BaseModel):
None, min_length=6, description="新密码(可选,留空表示不修改)"
)
reset_password: Optional[bool] = Field(False, description="是否清空密码")
allow_unverified_email: bool = Field(False, description="是否允许审批未验证邮箱用户")
class UserUpdateProfile(BaseModel):
@@ -42,6 +43,28 @@ class UserUpdateProfile(BaseModel):
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
class UserEmailUpdate(BaseModel):
"""用户设置邮箱并请求验证码 Schema"""
email: EmailStr = Field(..., description="邮箱地址")
class UserEmailVerify(BaseModel):
"""用户邮箱验证码校验 Schema"""
code: str = Field(..., min_length=4, max_length=12, description="邮箱验证码")
class AdminApprovalResponse(BaseModel):
"""管理员审批响应 Schema"""
success: bool
message: str
user_id: Optional[int] = None
requires_override: bool = False
warning_code: Optional[str] = None
class UserResponse(BaseModel):
"""用户响应 Schema"""
@@ -53,6 +76,8 @@ class UserResponse(BaseModel):
is_approved: bool
jwt_exp: str
email: Optional[EmailStr] = None
email_verified: bool = False
email_verified_at: Optional[datetime] = None
has_password: bool = False # 是否已设置密码
created_at: datetime
updated_at: Optional[datetime] = None
+34 -2
View File
@@ -11,6 +11,29 @@ logger = logging.getLogger(__name__)
class AdminService:
"""管理员服务"""
@staticmethod
def approval_warning_for_user(
user: User, allow_unverified_email: bool = False, next_email: str | None = None
) -> Dict[str, Any] | None:
from backend.services.email_settings_service import EmailSettingsService
email_changed = next_email is not None and next_email != user.email
has_verified_email = bool(user.email and user.email_verified_at and not email_changed)
should_warn = (
EmailSettingsService.should_warn_unverified_email_before_approval()
and not has_verified_email
)
if should_warn and not allow_unverified_email:
return {
"success": False,
"message": "用户邮箱未验证,确认后仍可继续审批",
"requires_override": True,
"warning_code": "UNVERIFIED_EMAIL",
}
if should_warn:
return {"warning_code": "UNVERIFIED_EMAIL"}
return None
@staticmethod
def get_pending_users(db: Session) -> List[User]:
"""获取待审批用户列表"""
@@ -24,7 +47,9 @@ class AdminService:
return users
@staticmethod
def approve_user(user_id: int, db: Session) -> Dict[str, Any]:
def approve_user(
user_id: int, db: Session, allow_unverified_email: bool = False
) -> Dict[str, Any]:
"""审批通过用户"""
user = db.query(User).filter(User.id == user_id).first()
@@ -34,13 +59,20 @@ class AdminService:
if user.is_approved:
return {"success": False, "message": "用户已经通过审批"}
warning = AdminService.approval_warning_for_user(user, allow_unverified_email)
if warning and warning.get("requires_override"):
return warning
user.is_approved = True
user.updated_at = datetime.now()
db.commit()
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
return {"success": True, "message": "审批成功", "user_id": user.id}
result = {"success": True, "message": "审批成功", "user_id": user.id}
if warning and warning.get("warning_code"):
result["warning_code"] = warning["warning_code"]
return result
@staticmethod
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
+4 -1
View File
@@ -226,13 +226,16 @@ class AuthService:
return {"status": "error", "message": "注册失败:用户名已被占用,请更换用户名"}
# 创建新用户(待审批状态)
from backend.services.email_settings_service import EmailSettingsService
requires_approval = EmailSettingsService.is_registration_approval_required()
new_user = User(
jwt_sub=jwt_sub,
alias=alias,
authorization=pure_token, # 存储清理后的 token
jwt_exp=jwt_exp,
role="user",
is_approved=False, # 待审批
is_approved=not requires_approval,
)
db.add(new_user)
+13
View File
@@ -159,6 +159,16 @@ class EmailService:
return EmailService.send_email(admin_emails, subject, body_html)
@staticmethod
def send_email_verification_code(to_email: str, alias: str, code: str) -> bool:
subject = f"【接龙自动打卡系统】邮箱验证码 - {alias}"
body_html = (
"<p>您正在验证接龙自动打卡系统账号邮箱。</p>"
f"<p>验证码:<strong>{_email_text(code)}</strong></p>"
"<p>验证码 10 分钟内有效。如非本人操作,请忽略本邮件。</p>"
)
return EmailService.send_email([to_email], subject, body_html)
@staticmethod
def notify_user_approved(user: User) -> bool:
"""
@@ -174,6 +184,9 @@ class EmailService:
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
return False
if not user.email_verified_at:
logger.info(f"用户 {user.alias} 邮箱未验证,跳过审批通知")
return False
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
body_html = render_email_template(
@@ -27,6 +27,8 @@ class EmailSettingsSnapshot:
smtp_use_ssl: bool
notify_token_expiring: bool
notify_check_in_success: bool
require_admin_approval_for_registration: bool
warn_unverified_email_before_approval: bool
has_smtp_sender_password: bool
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -46,6 +48,8 @@ class EmailSettingsService:
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
require_admin_approval_for_registration=True,
warn_unverified_email_before_approval=True,
)
@staticmethod
@@ -60,6 +64,8 @@ class EmailSettingsService:
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
require_admin_approval_for_registration=True,
warn_unverified_email_before_approval=True,
has_smtp_sender_password=bool(password),
created_at=None,
updated_at=None,
@@ -93,6 +99,10 @@ class EmailSettingsService:
smtp_use_ssl=bool(row.smtp_use_ssl),
notify_token_expiring=bool(row.notify_token_expiring),
notify_check_in_success=bool(row.notify_check_in_success),
require_admin_approval_for_registration=bool(
row.require_admin_approval_for_registration
),
warn_unverified_email_before_approval=bool(row.warn_unverified_email_before_approval),
has_smtp_sender_password=bool(password),
created_at=row.created_at,
updated_at=row.updated_at,
@@ -108,6 +118,8 @@ class EmailSettingsService:
smtp_use_ssl=snapshot.smtp_use_ssl,
notify_token_expiring=snapshot.notify_token_expiring,
notify_check_in_success=snapshot.notify_check_in_success,
require_admin_approval_for_registration=snapshot.require_admin_approval_for_registration,
warn_unverified_email_before_approval=snapshot.warn_unverified_email_before_approval,
has_smtp_sender_password=snapshot.has_smtp_sender_password,
created_at=snapshot.created_at,
updated_at=snapshot.updated_at,
@@ -132,6 +144,10 @@ class EmailSettingsService:
row.smtp_use_ssl = payload.smtp_use_ssl
row.notify_token_expiring = payload.notify_token_expiring
row.notify_check_in_success = payload.notify_check_in_success
row.require_admin_approval_for_registration = (
payload.require_admin_approval_for_registration
)
row.warn_unverified_email_before_approval = payload.warn_unverified_email_before_approval
if payload.clear_smtp_sender_password:
row.smtp_sender_password = ""
@@ -192,3 +208,29 @@ class EmailSettingsService:
return EmailSettingsService._default_snapshot().notify_check_in_success
finally:
db.close()
@staticmethod
def is_registration_approval_required() -> bool:
db = SessionLocal()
try:
try:
return EmailSettingsService.get_snapshot(db).require_admin_approval_for_registration
except SQLAlchemyError:
return (
EmailSettingsService._default_snapshot().require_admin_approval_for_registration
)
finally:
db.close()
@staticmethod
def should_warn_unverified_email_before_approval() -> bool:
db = SessionLocal()
try:
try:
return EmailSettingsService.get_snapshot(db).warn_unverified_email_before_approval
except SQLAlchemyError:
return (
EmailSettingsService._default_snapshot().warn_unverified_email_before_approval
)
finally:
db.close()
+88 -5
View File
@@ -1,8 +1,12 @@
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
import bcrypt
from email_validator import validate_email, EmailNotValidError
from sqlalchemy import or_
from sqlalchemy.orm import Session
from backend.models import User
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
@@ -26,6 +30,19 @@ def escape_like_pattern(text: str) -> str:
class UserService:
"""用户服务"""
@staticmethod
def _clear_email_verification(user: User) -> None:
user.email_verified_at = None
user.email_verification_code_hash = None
user.email_verification_expires_at = None
@staticmethod
def _normalize_email(email: str) -> str:
try:
return str(validate_email(email.strip(), check_deliverability=False).normalized)
except EmailNotValidError as exc:
raise ValueError("邮箱格式无效") from exc
@staticmethod
def create_user(user_data: UserCreate, db: Session) -> User:
"""
@@ -47,7 +64,7 @@ class UserService:
user = User(
jwt_sub=None, # NULL 表示未绑定 QQ
alias=user_data.alias,
email=user_data.email,
email=str(user_data.email) if user_data.email is not None else None,
role=user_data.role or "user",
is_approved=user_data.is_approved
if user_data.is_approved is not None
@@ -192,7 +209,14 @@ class UserService:
logger.info(f"管理员修改用户 {user.alias} (ID: {user_id}) 的密码")
# 更新其他字段(排除密码相关字段)
excluded_fields = {"password", "reset_password"}
excluded_fields = {"password", "reset_password", "allow_unverified_email"}
if "email" in update_data:
next_email = update_data["email"]
next_email = str(next_email) if next_email is not None else None
if next_email != user.email:
UserService._clear_email_verification(user)
update_data["email"] = next_email
for key, value in update_data.items():
if key not in excluded_fields:
setattr(user, key, value)
@@ -235,7 +259,10 @@ class UserService:
# 更新邮箱
if "email" in update_data:
user.email = update_data["email"]
next_email = str(update_data["email"]) if update_data["email"] is not None else None
if next_email != user.email:
UserService._clear_email_verification(user)
user.email = next_email
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
# 更新密码
@@ -262,6 +289,62 @@ class UserService:
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
return user
@staticmethod
def set_email_for_verification(user_id: int, email: str, db: Session) -> User:
from backend.services.email_service import EmailService
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
normalized_email = UserService._normalize_email(email)
if normalized_email != user.email:
user.email = normalized_email
UserService._clear_email_verification(user)
code = f"{secrets.randbelow(1_000_000):06d}"
code_hash = bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
user.email_verification_code_hash = code_hash
user.email_verification_expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
user.updated_at = datetime.now(timezone.utc)
sent = EmailService.send_email_verification_code(normalized_email, user.alias, code)
if not sent:
db.rollback()
raise ValueError("验证码邮件发送失败,请检查邮件配置后重试")
db.commit()
db.refresh(user)
logger.info("用户 ID %s 已请求邮箱验证码: %s", user_id, normalized_email)
return user
@staticmethod
def verify_email_code(user_id: int, code: str, db: Session) -> User:
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
code_hash = user.email_verification_code_hash
expires_at = user.email_verification_expires_at
now = datetime.now(timezone.utc)
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if not code_hash or not expires_at or expires_at <= now:
raise ValueError("验证码无效或已过期")
if not bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("utf-8")):
raise ValueError("验证码无效或已过期")
user.email_verified_at = now
user.email_verification_code_hash = None
user.email_verification_expires_at = None
user.updated_at = now
db.commit()
db.refresh(user)
logger.info("用户 ID %s 邮箱验证成功: %s", user_id, user.email)
return user
@staticmethod
def delete_user(user_id: int, db: Session) -> bool:
"""
+11 -4
View File
@@ -1,5 +1,6 @@
import { apiClient } from './client'
import type {
AdminApprovalResponse,
AdminStats,
CheckInRecord,
CheckInRecordStatus,
@@ -45,6 +46,8 @@ export const userApi = {
me: () => apiClient.get<User>('/api/users/me'),
status: () => apiClient.get<UserStatus>('/api/users/me/status'),
tokenStatus: () => apiClient.get<TokenStatus>('/api/users/me/token_status'),
setEmail: (email: string) => apiClient.put<User>('/api/users/me/email', { email }),
verifyEmail: (code: string) => apiClient.post<User>('/api/users/me/email/verify', { code }),
updateProfile: (payload: {
alias?: string
email?: string
@@ -56,8 +59,12 @@ export const userApi = {
apiClient.post<User>('/api/users', payload),
update: (
userId: number,
payload: Partial<User> & { password?: string; reset_password?: boolean },
) => apiClient.put<User>(`/api/users/${userId}`, payload),
payload: Partial<User> & {
password?: string
reset_password?: boolean
allow_unverified_email?: boolean
},
) => apiClient.put<User | AdminApprovalResponse>(`/api/users/${userId}`, payload),
delete: (userId: number) => apiClient.delete<void>(`/api/users/${userId}`),
}
@@ -104,8 +111,8 @@ export const templateApi = {
export const adminApi = {
pendingUsers: () => apiClient.get<User[]>('/api/admin/users/pending'),
approveUser: (userId: number) =>
apiClient.post<{ success: boolean; message: string }>(`/api/admin/users/${userId}/approve`),
approveUser: (userId: number, payload: { allow_unverified_email?: boolean } = {}) =>
apiClient.post<AdminApprovalResponse>(`/api/admin/users/${userId}/approve`, payload),
rejectUser: (userId: number) =>
apiClient.delete<{ success: boolean; message: string }>(`/api/admin/users/${userId}/reject`),
stats: () => apiClient.get<AdminStats>('/api/admin/stats'),
+16
View File
@@ -7,6 +7,8 @@ export interface User {
is_approved: boolean
jwt_exp: string
email?: string | null
email_verified?: boolean
email_verified_at?: string | null
has_password?: boolean
created_at: string
updated_at?: string | null
@@ -35,6 +37,8 @@ export interface AuthUserPayload {
is_approved?: boolean
jwt_exp?: string
email?: string | null
email_verified?: boolean
email_verified_at?: string | null
has_password?: boolean
created_at?: string
updated_at?: string | null
@@ -210,6 +214,8 @@ export interface EmailNotificationSettings {
smtp_use_ssl: boolean
notify_token_expiring: boolean
notify_check_in_success: boolean
require_admin_approval_for_registration: boolean
warn_unverified_email_before_approval: boolean
has_smtp_sender_password: boolean
created_at?: string | null
updated_at?: string | null
@@ -222,10 +228,20 @@ export interface EmailNotificationSettingsUpdate {
smtp_use_ssl: boolean
notify_token_expiring: boolean
notify_check_in_success: boolean
require_admin_approval_for_registration: boolean
warn_unverified_email_before_approval: boolean
smtp_sender_password?: string
clear_smtp_sender_password?: boolean
}
export interface AdminApprovalResponse {
success: boolean
message: string
user_id?: number
requires_override?: boolean
warning_code?: string
}
export interface CronValidation {
valid: boolean
message: string
+2
View File
@@ -33,6 +33,8 @@ function userFromLogin(payload: LoginResponse): User | null {
is_approved: raw?.is_approved ?? payload.is_approved ?? false,
jwt_exp: raw?.jwt_exp ?? '',
email: raw?.email ?? null,
email_verified: raw?.email_verified ?? false,
email_verified_at: raw?.email_verified_at ?? null,
has_password: raw?.has_password,
created_at: raw?.created_at ?? new Date().toISOString(),
updated_at: raw?.updated_at ?? null,
+108 -3
View File
@@ -1,17 +1,26 @@
<script setup lang="ts">
import { RefreshCw } from 'lucide-vue-next'
import { ref } from 'vue'
import { MailCheck, RefreshCw, Send } from 'lucide-vue-next'
import { computed, reactive, ref } from 'vue'
import { userApi } from '@/api'
import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router'
import { alertClass, cardClass, toneClass } from '@/components/ui'
import { alertClass, cardClass, inputClass, labelClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
const auth = useAuth()
const router = useRouter()
const loading = ref(false)
const sendingCode = ref(false)
const verifying = ref(false)
const error = ref('')
const emailMessage = ref('')
const emailForm = reactive({
email: auth.state.user?.email ?? '',
code: '',
})
const emailVerified = computed(() => Boolean(auth.state.user?.email_verified))
async function refresh() {
loading.value = true
@@ -28,6 +37,37 @@ async function refresh() {
loading.value = false
}
}
async function requestEmailCode() {
sendingCode.value = true
error.value = ''
emailMessage.value = ''
try {
const user = await userApi.setEmail(emailForm.email)
auth.state.user = user
emailForm.email = user.email ?? emailForm.email
emailMessage.value = '验证码已发送,请检查邮箱'
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
sendingCode.value = false
}
}
async function verifyEmail() {
verifying.value = true
error.value = ''
emailMessage.value = ''
try {
const user = await userApi.verifyEmail(emailForm.code)
auth.state.user = user
emailMessage.value = '邮箱已验证,账号已进入正常审批流程'
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
verifying.value = false
}
}
</script>
<template>
@@ -39,6 +79,9 @@ async function refresh() {
当前账号
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录但还需要管理员审批后才能访问工作台
</p>
<p class="mt-3 text-sm font-medium text-[var(--tone-warning-fg)]">
请填写并验证邮箱只有邮箱完成验证后账号才会进入正常审批流程未验证的待审批账号可能会在清理窗口后自动吊销
</p>
</div>
<div class="p-6">
<dl class="mt-5 grid gap-3 sm:grid-cols-2">
@@ -52,7 +95,69 @@ async function refresh() {
<dt class="text-xs text-muted-foreground">审批状态</dt>
<dd class="mt-1 text-sm font-medium">待审批</dd>
</div>
<div class="rounded-md border border-border bg-background p-3">
<dt class="text-xs text-muted-foreground">邮箱状态</dt>
<dd class="mt-1 text-sm font-medium">
{{ emailVerified ? '已验证' : '未验证' }}
</dd>
</div>
</dl>
<form class="mt-5 grid gap-4 rounded-md border border-border bg-background p-4">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="font-semibold">邮箱验证</h3>
</div>
<span :class="toneClass(emailVerified ? 'success' : 'warning')">
{{ emailVerified ? '已验证' : '待验证' }}
</span>
</div>
<label class="grid gap-2">
<span :class="labelClass">邮箱</span>
<input
v-model.trim="emailForm.email"
:class="inputClass"
type="email"
placeholder="用于审批通知"
:disabled="emailVerified"
/>
</label>
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<label class="grid gap-2">
<span :class="labelClass">验证码</span>
<input
v-model.trim="emailForm.code"
:class="inputClass"
inputmode="numeric"
placeholder="请输入邮箱验证码"
:disabled="emailVerified"
/>
</label>
<div class="flex items-end gap-2">
<Button
variant="outline"
type="button"
:disabled="sendingCode || emailVerified || !emailForm.email"
@click="requestEmailCode"
>
<Send class="size-4" :class="{ 'animate-spin': sendingCode }" />
发送验证码
</Button>
<Button
type="button"
:disabled="verifying || emailVerified || !emailForm.code"
@click="verifyEmail"
>
<MailCheck class="size-4" :class="{ 'animate-spin': verifying }" />
验证
</Button>
</div>
</div>
<div v-if="emailMessage" :class="alertClass.success">
{{ emailMessage }}
</div>
</form>
<div v-if="error" :class="[alertClass.danger, 'mt-4']">
{{ error }}
</div>
@@ -28,6 +28,8 @@ const form = reactive({
smtp_use_ssl: true,
notify_token_expiring: true,
notify_check_in_success: true,
require_admin_approval_for_registration: true,
warn_unverified_email_before_approval: true,
})
const passwordState = computed(() => {
@@ -47,6 +49,8 @@ function hydrate(next: EmailNotificationSettings) {
form.smtp_use_ssl = next.smtp_use_ssl
form.notify_token_expiring = next.notify_token_expiring
form.notify_check_in_success = next.notify_check_in_success
form.require_admin_approval_for_registration = next.require_admin_approval_for_registration
form.warn_unverified_email_before_approval = next.warn_unverified_email_before_approval
}
async function load() {
@@ -63,6 +67,12 @@ async function load() {
}
async function save() {
if (!form.require_admin_approval_for_registration) {
const ok = window.confirm(
'关闭管理员审批后,新注册用户可能绕过人工审核直接进入系统。确认关闭管理员审批?',
)
if (!ok) return
}
saving.value = true
error.value = ''
savedMessage.value = ''
@@ -74,6 +84,8 @@ async function save() {
smtp_use_ssl: form.smtp_use_ssl,
notify_token_expiring: form.notify_token_expiring,
notify_check_in_success: form.notify_check_in_success,
require_admin_approval_for_registration: form.require_admin_approval_for_registration,
warn_unverified_email_before_approval: form.warn_unverified_email_before_approval,
smtp_sender_password: form.smtp_sender_password || undefined,
clear_smtp_sender_password: form.clear_smtp_sender_password,
})
@@ -205,6 +217,34 @@ onMounted(load)
<span class="text-sm text-muted-foreground">只影响过期前提醒不影响已过期通知</span>
</label>
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
<span class="flex items-center justify-between gap-3 text-sm font-medium">
管理员审批
<input
v-model="form.require_admin_approval_for_registration"
type="checkbox"
class="size-4 accent-primary"
/>
</span>
<span class="text-sm text-muted-foreground"
>默认开启关闭管理员审批会让新注册用户绕过人工审核</span
>
</label>
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
<span class="flex items-center justify-between gap-3 text-sm font-medium">
未验证邮箱审批警告
<input
v-model="form.warn_unverified_email_before_approval"
type="checkbox"
class="size-4 accent-primary"
/>
</span>
<span class="text-sm text-muted-foreground"
>开启后管理员审批邮箱未验证用户时需要确认覆盖</span
>
</label>
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
<span class="flex items-center justify-between gap-3 text-sm font-medium">
打卡成功通知
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Check, Save, Search, Trash2, UserPlus } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue'
import { adminApi, userApi, type User } from '@/api'
import { adminApi, userApi, type AdminApprovalResponse, type User } from '@/api'
import StateBlock from '@/components/StateBlock.vue'
import {
alertClass,
@@ -27,6 +27,12 @@ const form = reactive({
is_approved: true,
})
function requiresUnverifiedEmailOverride(
result: User | AdminApprovalResponse,
): result is AdminApprovalResponse {
return 'requires_override' in result && result.warning_code === 'UNVERIFIED_EMAIL'
}
async function load() {
loading.value = true
error.value = ''
@@ -40,7 +46,12 @@ async function load() {
}
async function approve(userId: number) {
await adminApi.approveUser(userId)
const result = await adminApi.approveUser(userId)
if (requiresUnverifiedEmailOverride(result)) {
const ok = window.confirm('邮箱未验证,审批后不会发送审批通知。确认无视邮箱条件继续审批?')
if (!ok) return
await adminApi.approveUser(userId, { allow_unverified_email: true })
}
await load()
}
@@ -81,7 +92,12 @@ async function save() {
if (editingId.value === 'new') {
await userApi.create(payload)
} else if (typeof editingId.value === 'number') {
await userApi.update(editingId.value, payload)
const result = await userApi.update(editingId.value, payload)
if (requiresUnverifiedEmailOverride(result)) {
const ok = window.confirm('邮箱未验证,审批后不会发送审批通知。确认无视邮箱条件继续审批?')
if (!ok) return
await userApi.update(editingId.value, { ...payload, allow_unverified_email: true })
}
}
editingId.value = null
await load()
@@ -145,6 +161,7 @@ onMounted(load)
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span>{{ user.email || '未设置邮箱' }}</span>
<span>{{ user.email_verified ? '邮箱已验证' : '邮箱未验证' }}</span>
<span>{{ formatDateTime(user.created_at) }}</span>
</div>
</div>
+41
View File
@@ -17,6 +17,9 @@ from backend.migration_steps.email_notification_settings import (
apply as apply_email_notification_settings,
)
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
from backend.migration_steps.user_email_verification import (
apply as apply_user_email_verification,
)
def test_pending_migration_is_recorded_and_skipped_on_next_run() -> None:
@@ -79,11 +82,13 @@ def test_existing_migrations_are_registered_in_order() -> None:
"2026050401_add_account_lockout",
"2026050402_add_task_thread_id",
"2026050501_add_email_notification_settings",
"2026050601_add_user_email_verification",
]
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
"backend.migration_steps.account_lockout",
"backend.migration_steps.task_thread_id",
"backend.migration_steps.email_notification_settings",
"backend.migration_steps.user_email_verification",
]
@@ -105,9 +110,45 @@ def test_email_notification_settings_migration_creates_settings_table() -> None:
"smtp_use_ssl",
"notify_token_expiring",
"notify_check_in_success",
"require_admin_approval_for_registration",
"warn_unverified_email_before_approval",
} <= columns
def test_user_email_verification_migration_adds_user_fields_and_policy_flags() -> None:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
with engine.connect() as conn:
conn.execute(text("CREATE TABLE users (id INTEGER PRIMARY KEY, alias VARCHAR(50))"))
conn.execute(
text(
"CREATE TABLE email_notification_settings ("
"id INTEGER PRIMARY KEY, "
"notify_token_expiring BOOLEAN NOT NULL DEFAULT 1, "
"notify_check_in_success BOOLEAN NOT NULL DEFAULT 1"
")"
)
)
conn.commit()
apply_user_email_verification(conn)
user_columns = {row[1] for row in conn.execute(text("PRAGMA table_info(users)"))}
settings_columns = {
row[1] for row in conn.execute(text("PRAGMA table_info(email_notification_settings)"))
}
assert {
"email_verified_at",
"email_verification_code_hash",
"email_verification_expires_at",
} <= user_columns
assert {
"require_admin_approval_for_registration",
"warn_unverified_email_before_approval",
} <= settings_columns
def test_account_lockout_migration_adds_missing_user_fields() -> None:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
+17
View File
@@ -74,6 +74,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
assert updated.smtp_sender_password == "new-secret"
assert updated.notify_token_expiring is False
assert updated.notify_check_in_success is False
assert updated.require_admin_approval_for_registration is True
assert updated.warn_unverified_email_before_approval is True
EmailSettingsService.update_settings(
session,
@@ -84,6 +86,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
smtp_use_ssl=True,
notify_token_expiring=False,
notify_check_in_success=False,
require_admin_approval_for_registration=False,
warn_unverified_email_before_approval=False,
clear_smtp_sender_password=True,
),
)
@@ -91,6 +95,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
cleared = EmailSettingsService.get_snapshot(session)
assert cleared.smtp_sender_password in ("", None)
assert cleared.has_smtp_sender_password is False
assert cleared.require_admin_approval_for_registration is False
assert cleared.warn_unverified_email_before_approval is False
session.close()
engine.dispose()
@@ -195,3 +201,14 @@ def test_email_settings_update_validates_sender_email() -> None:
notify_token_expiring=True,
notify_check_in_success=True,
)
def test_registration_approval_policy_defaults_enabled() -> None:
engine, _, session = make_session()
snapshot = EmailSettingsService.get_snapshot(session)
assert snapshot.require_admin_approval_for_registration is True
assert snapshot.warn_unverified_email_before_approval is True
session.close()
engine.dispose()
+4 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -213,9 +213,9 @@ def test_user_approval_notification_uses_template_login_link(monkeypatch) -> Non
monkeypatch.setattr(EmailService, "send_email", fake_send)
assert (
EmailService.notify_user_approved(User(alias="Alice", email="alice@example.test")) is True
)
user = User(alias="Alice", email="alice@example.test")
user.email_verified_at = datetime.now(timezone.utc)
assert EmailService.notify_user_approved(user) is True
assert sent["to_emails"] == ["alice@example.test"]
assert "账户审批通过" in str(sent["subject"])
+22
View File
@@ -65,6 +65,28 @@ def test_frontend_admin_api_covers_email_settings() -> None:
assert "/api/admin/email_settings" in api
def test_frontend_covers_pending_email_verification_flow() -> None:
api = (SRC_ROOT / "api" / "index.ts").read_text(encoding="utf-8")
pending = (SRC_ROOT / "views" / "PendingApprovalView.vue").read_text(encoding="utf-8")
assert "/api/users/me/email" in api
assert "/api/users/me/email/verify" in api
assert "自动吊销" in pending
assert "验证码" in pending
def test_frontend_admin_approval_policy_warnings_are_visible() -> None:
admin_users = (SRC_ROOT / "views" / "admin" / "AdminUsersView.vue").read_text(encoding="utf-8")
email_settings = (SRC_ROOT / "views" / "admin" / "AdminEmailSettingsView.vue").read_text(
encoding="utf-8"
)
assert "allow_unverified_email" in admin_users
assert "邮箱未验证" in admin_users
assert "require_admin_approval_for_registration" in email_settings
assert "关闭管理员审批" in email_settings
def test_frontend_replaces_starter_component() -> None:
app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8")
+269
View File
@@ -0,0 +1,269 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.api import users as users_api
from backend.dependencies import get_current_user, get_db
from backend.models import Base, User
from backend.schemas.email_settings import EmailNotificationSettingsUpdate
from backend.schemas.user import UserUpdate
from backend.services import email_settings_service
from backend.services.admin_service import AdminService
from backend.services.email_service import EmailService
from backend.services.email_settings_service import EmailSettingsService
from backend.services.user_service import UserService
def make_session():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = session_factory()
return engine, session_factory, session
def add_user(session, alias: str = "Alice", email: str | None = None) -> User:
user = User(alias=alias, email=email, role="user", is_approved=False, jwt_exp="0")
session.add(user)
session.commit()
session.refresh(user)
return user
def test_current_user_can_set_and_verify_email(monkeypatch) -> None:
engine, _, session = make_session()
user = add_user(session)
sent: dict[str, object] = {}
def fake_send_verification_code(to_email: str, alias: str, code: str) -> bool:
sent["to_email"] = to_email
sent["alias"] = alias
sent["code"] = code
return True
monkeypatch.setattr(EmailService, "send_email_verification_code", fake_send_verification_code)
result = UserService.set_email_for_verification(user.id, " Alice@Example.COM ", session)
assert result.email == "Alice@example.com"
assert result.email_verified is False
assert sent["to_email"] == "Alice@example.com"
assert isinstance(sent["code"], str)
assert sent["code"] != ""
verified = UserService.verify_email_code(user.id, str(sent["code"]), session)
assert verified.email_verified is True
assert verified.email_verified_at is not None
refreshed = session.get(User, user.id)
assert refreshed is not None
assert refreshed.email_verification_code_hash is None
assert refreshed.email_verification_expires_at is None
session.close()
engine.dispose()
def test_email_verification_rejects_wrong_or_expired_code(monkeypatch) -> None:
engine, _, session = make_session()
user = add_user(session)
sent: dict[str, str] = {}
monkeypatch.setattr(
EmailService,
"send_email_verification_code",
lambda to_email, alias, code: sent.setdefault("code", code) or True,
)
UserService.set_email_for_verification(user.id, "alice@example.com", session)
with pytest.raises(ValueError, match="验证码"):
UserService.verify_email_code(user.id, "000000", session)
refreshed = session.get(User, user.id)
assert refreshed is not None
refreshed.email_verification_expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
session.commit()
with pytest.raises(ValueError, match="验证码"):
UserService.verify_email_code(user.id, sent["code"], session)
session.close()
engine.dispose()
def test_changing_email_clears_verification_state(monkeypatch) -> None:
engine, _, session = make_session()
user = add_user(session, email="old@example.com")
user.email_verified_at = datetime.now(timezone.utc)
session.commit()
monkeypatch.setattr(EmailService, "send_email_verification_code", lambda *args: True)
result = UserService.set_email_for_verification(user.id, "new@example.com", session)
assert result.email == "new@example.com"
assert result.email_verified is False
refreshed = session.get(User, user.id)
assert refreshed is not None
assert refreshed.email_verified_at is None
session.close()
engine.dispose()
def test_approval_requires_warning_then_allows_override(monkeypatch) -> None:
engine, session_factory, session = make_session()
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
user = add_user(session)
first = AdminService.approve_user(user.id, session)
assert first["success"] is False
assert first["requires_override"] is True
assert first["warning_code"] == "UNVERIFIED_EMAIL"
second = AdminService.approve_user(user.id, session, allow_unverified_email=True)
assert second["success"] is True
assert second["warning_code"] == "UNVERIFIED_EMAIL"
assert session.get(User, user.id).is_approved is True
session.close()
engine.dispose()
def test_approval_warning_can_be_disabled(monkeypatch) -> None:
engine, session_factory, session = make_session()
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
EmailSettingsService.update_settings(
session,
EmailNotificationSettingsUpdate(
smtp_server="smtp.example.test",
smtp_port=465,
smtp_sender_email="mailer@example.com",
smtp_use_ssl=True,
notify_token_expiring=True,
notify_check_in_success=True,
require_admin_approval_for_registration=True,
warn_unverified_email_before_approval=False,
),
)
user = add_user(session)
result = AdminService.approve_user(user.id, session)
assert result["success"] is True
assert "requires_override" not in result
session.close()
engine.dispose()
def test_approval_notification_requires_verified_email(monkeypatch) -> None:
sent: dict[str, object] = {}
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
sent["to_emails"] = to_emails
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
unverified = User(alias="Alice", email="alice@example.com")
assert EmailService.notify_user_approved(unverified) is False
assert sent == {}
verified = User(alias="Bob", email="bob@example.com")
verified.email_verified_at = datetime.now(timezone.utc)
assert EmailService.notify_user_approved(verified) is True
assert sent["to_emails"] == ["bob@example.com"]
def test_registration_approval_policy_controls_new_user_approval(monkeypatch) -> None:
engine, session_factory, session = make_session()
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
EmailSettingsService.update_settings(
session,
EmailNotificationSettingsUpdate(
smtp_server="smtp.example.test",
smtp_port=465,
smtp_sender_email="mailer@example.com",
smtp_use_ssl=True,
notify_token_expiring=True,
notify_check_in_success=True,
require_admin_approval_for_registration=False,
warn_unverified_email_before_approval=True,
),
)
assert EmailSettingsService.is_registration_approval_required() is False
session.close()
engine.dispose()
def test_admin_update_warns_when_approval_changes_email_before_approval(monkeypatch) -> None:
engine, session_factory, session = make_session()
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
user = add_user(session, email="old@example.com")
user.email_verified_at = datetime.now(timezone.utc)
session.commit()
warning = AdminService.approval_warning_for_user(
user,
allow_unverified_email=False,
next_email="new@example.com",
)
assert warning is not None
assert warning["requires_override"] is True
UserService.update_user(
user.id,
UserUpdate(email="new@example.com", is_approved=True, allow_unverified_email=True),
session,
)
refreshed = session.get(User, user.id)
assert refreshed is not None
assert refreshed.email == "new@example.com"
assert refreshed.email_verified is False
session.close()
engine.dispose()
def test_admin_update_route_returns_unverified_email_warning(monkeypatch) -> None:
engine, session_factory, session = make_session()
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
admin = User(alias="Admin", role="admin", is_approved=True, jwt_exp="0")
user = User(alias="Alice", role="user", is_approved=False, jwt_exp="0")
session.add_all([admin, user])
session.commit()
session.refresh(admin)
session.refresh(user)
app = FastAPI()
app.include_router(users_api.router, prefix="/api/users")
def override_get_db():
yield session
async def override_get_current_user() -> User:
return admin
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
client = TestClient(app)
response = client.put(f"/api/users/{user.id}", json={"is_approved": True})
assert response.status_code == 200
assert response.json() == {
"success": False,
"message": "用户邮箱未验证,确认后仍可继续审批",
"user_id": None,
"requires_override": True,
"warning_code": "UNVERIFIED_EMAIL",
}
assert session.get(User, user.id).is_approved is False
session.close()
engine.dispose()