mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(auth): require verified email for approval
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
import logging
|
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 sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from backend.schemas.email_settings import (
|
|||||||
EmailNotificationSettingsResponse,
|
EmailNotificationSettingsResponse,
|
||||||
EmailNotificationSettingsUpdate,
|
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.check_in_service import CheckInService
|
||||||
from backend.services.admin_service import AdminService
|
from backend.services.admin_service import AdminService
|
||||||
from backend.services.email_settings_service import EmailSettingsService
|
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(
|
async def approve_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
payload: dict = Body(default_factory=dict),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_admin_user),
|
current_user: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
@@ -332,9 +337,15 @@ async def approve_user(
|
|||||||
审批通过指定用户(需要管理员权限)
|
审批通过指定用户(需要管理员权限)
|
||||||
"""
|
"""
|
||||||
try:
|
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 not result["success"]:
|
||||||
|
if result.get("requires_override"):
|
||||||
|
return result
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["message"])
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["message"])
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.models import get_db, User
|
from backend.models import get_db, User
|
||||||
from backend.schemas.user import (
|
from backend.schemas.user import (
|
||||||
|
AdminApprovalResponse,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
TokenStatus,
|
TokenStatus,
|
||||||
UserUpdateProfile,
|
UserUpdateProfile,
|
||||||
|
UserEmailUpdate,
|
||||||
|
UserEmailVerify,
|
||||||
)
|
)
|
||||||
from backend.schemas.task import TaskResponse
|
from backend.schemas.task import TaskResponse
|
||||||
from backend.services.user_service import UserService
|
from backend.services.user_service import UserService
|
||||||
from backend.services.task_service import TaskService
|
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.dependencies import get_current_user, get_current_admin_user
|
||||||
from backend.exceptions import (
|
from backend.exceptions import (
|
||||||
AuthorizationError,
|
AuthorizationError,
|
||||||
@@ -69,6 +73,8 @@ async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
|||||||
"is_approved": current_user.is_approved,
|
"is_approved": current_user.is_approved,
|
||||||
"jwt_exp": current_user.jwt_exp,
|
"jwt_exp": current_user.jwt_exp,
|
||||||
"email": current_user.email,
|
"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),
|
"has_password": bool(current_user.password_hash),
|
||||||
"created_at": current_user.created_at,
|
"created_at": current_user.created_at,
|
||||||
"updated_at": current_user.updated_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,
|
"user_id": current_user.id,
|
||||||
"alias": current_user.alias,
|
"alias": current_user.alias,
|
||||||
"is_approved": current_user.is_approved,
|
"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,
|
"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="更新个人信息")
|
@router.put("/me/profile", response_model=UserResponse, summary="更新个人信息")
|
||||||
async def update_current_user_profile(
|
async def update_current_user_profile(
|
||||||
profile_data: UserUpdateProfile,
|
profile_data: UserUpdateProfile,
|
||||||
@@ -211,7 +264,11 @@ async def get_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}", response_model=UserResponse, summary="更新用户信息")
|
@router.put(
|
||||||
|
"/{user_id}",
|
||||||
|
response_model=UserResponse | AdminApprovalResponse,
|
||||||
|
summary="更新用户信息",
|
||||||
|
)
|
||||||
async def update_user(
|
async def update_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
user_data: UserUpdate,
|
user_data: UserUpdate,
|
||||||
@@ -241,6 +298,19 @@ async def update_user(
|
|||||||
# 保存更新前的审批状态 (先读取后转换为 Python bool)
|
# 保存更新前的审批状态 (先读取后转换为 Python bool)
|
||||||
old_approved_value = old_user.is_approved
|
old_approved_value = old_user.is_approved
|
||||||
was_approved_before = True if old_approved_value else False
|
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)
|
user = UserService.update_user(user_id, user_data, db)
|
||||||
@@ -249,7 +319,6 @@ async def update_user(
|
|||||||
new_approved_value = user.is_approved
|
new_approved_value = user.is_approved
|
||||||
is_approved_now = True if new_approved_value else False
|
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
|
needs_notification = is_admin and (not was_approved_before) and is_approved_now
|
||||||
|
|
||||||
if needs_notification:
|
if needs_notification:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ def apply(conn: Connection) -> None:
|
|||||||
smtp_use_ssl BOOLEAN NOT NULL DEFAULT 1,
|
smtp_use_ssl BOOLEAN NOT NULL DEFAULT 1,
|
||||||
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
|
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
|
||||||
notify_check_in_success 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,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME
|
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",
|
||||||
|
)
|
||||||
@@ -13,6 +13,9 @@ from backend.migration_steps.email_notification_settings import (
|
|||||||
apply as apply_email_notification_settings,
|
apply as apply_email_notification_settings,
|
||||||
)
|
)
|
||||||
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
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
|
from backend.models.database import engine as default_engine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -94,6 +97,11 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
description="Add admin-managed email notification settings.",
|
description="Add admin-managed email notification settings.",
|
||||||
apply=apply_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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ class EmailNotificationSettings(Base):
|
|||||||
smtp_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
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_token_expiring: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
notify_check_in_success: 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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
|
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ class User(Base):
|
|||||||
email: Mapped[str | None] = mapped_column(
|
email: Mapped[str | None] = mapped_column(
|
||||||
String(100), nullable=True, comment="用户邮箱(用于接收通知)"
|
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(
|
password_hash: Mapped[str | None] = mapped_column(
|
||||||
String(200), nullable=True, comment="密码哈希(bcrypt加密)"
|
String(200), nullable=True, comment="密码哈希(bcrypt加密)"
|
||||||
)
|
)
|
||||||
@@ -91,3 +100,8 @@ class User(Base):
|
|||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
"""判断是否为管理员"""
|
"""判断是否为管理员"""
|
||||||
return self.role == "admin"
|
return self.role == "admin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email_verified(self) -> bool:
|
||||||
|
"""判断当前邮箱是否已验证"""
|
||||||
|
return bool(self.email and self.email_verified_at)
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from backend.schemas.user import (
|
|||||||
UserResponse,
|
UserResponse,
|
||||||
UserWithToken,
|
UserWithToken,
|
||||||
TokenStatus,
|
TokenStatus,
|
||||||
|
UserEmailUpdate,
|
||||||
|
UserEmailVerify,
|
||||||
|
AdminApprovalResponse,
|
||||||
)
|
)
|
||||||
from backend.schemas.auth import (
|
from backend.schemas.auth import (
|
||||||
QRCodeRequest,
|
QRCodeRequest,
|
||||||
@@ -49,6 +52,9 @@ __all__ = [
|
|||||||
"UserResponse",
|
"UserResponse",
|
||||||
"UserWithToken",
|
"UserWithToken",
|
||||||
"TokenStatus",
|
"TokenStatus",
|
||||||
|
"UserEmailUpdate",
|
||||||
|
"UserEmailVerify",
|
||||||
|
"AdminApprovalResponse",
|
||||||
"QRCodeRequest",
|
"QRCodeRequest",
|
||||||
"QRCodeResponse",
|
"QRCodeResponse",
|
||||||
"QRCodeStatusResponse",
|
"QRCodeStatusResponse",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ class EmailNotificationSettingsBase(BaseModel):
|
|||||||
smtp_use_ssl: bool = Field(True, description="是否使用 SMTP SSL")
|
smtp_use_ssl: bool = Field(True, description="是否使用 SMTP SSL")
|
||||||
notify_token_expiring: bool = Field(True, description="是否通知 Token 即将过期")
|
notify_token_expiring: bool = Field(True, description="是否通知 Token 即将过期")
|
||||||
notify_check_in_success: bool = Field(True, description="是否通知打卡成功")
|
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")
|
@field_validator("smtp_server", "smtp_sender_email", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class UserUpdate(BaseModel):
|
|||||||
None, min_length=6, description="新密码(可选,留空表示不修改)"
|
None, min_length=6, description="新密码(可选,留空表示不修改)"
|
||||||
)
|
)
|
||||||
reset_password: Optional[bool] = Field(False, description="是否清空密码")
|
reset_password: Optional[bool] = Field(False, description="是否清空密码")
|
||||||
|
allow_unverified_email: bool = Field(False, description="是否允许审批未验证邮箱用户")
|
||||||
|
|
||||||
|
|
||||||
class UserUpdateProfile(BaseModel):
|
class UserUpdateProfile(BaseModel):
|
||||||
@@ -42,6 +43,28 @@ class UserUpdateProfile(BaseModel):
|
|||||||
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
|
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):
|
class UserResponse(BaseModel):
|
||||||
"""用户响应 Schema"""
|
"""用户响应 Schema"""
|
||||||
|
|
||||||
@@ -53,6 +76,8 @@ class UserResponse(BaseModel):
|
|||||||
is_approved: bool
|
is_approved: bool
|
||||||
jwt_exp: str
|
jwt_exp: str
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
|
email_verified: bool = False
|
||||||
|
email_verified_at: Optional[datetime] = None
|
||||||
has_password: bool = False # 是否已设置密码
|
has_password: bool = False # 是否已设置密码
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|||||||
@@ -11,6 +11,29 @@ logger = logging.getLogger(__name__)
|
|||||||
class AdminService:
|
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
|
@staticmethod
|
||||||
def get_pending_users(db: Session) -> List[User]:
|
def get_pending_users(db: Session) -> List[User]:
|
||||||
"""获取待审批用户列表"""
|
"""获取待审批用户列表"""
|
||||||
@@ -24,7 +47,9 @@ class AdminService:
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
@staticmethod
|
@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()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
@@ -34,13 +59,20 @@ class AdminService:
|
|||||||
if user.is_approved:
|
if user.is_approved:
|
||||||
return {"success": False, "message": "用户已经通过审批"}
|
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.is_approved = True
|
||||||
user.updated_at = datetime.now()
|
user.updated_at = datetime.now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
|
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
|
@staticmethod
|
||||||
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
|
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -226,13 +226,16 @@ class AuthService:
|
|||||||
return {"status": "error", "message": "注册失败:用户名已被占用,请更换用户名"}
|
return {"status": "error", "message": "注册失败:用户名已被占用,请更换用户名"}
|
||||||
|
|
||||||
# 创建新用户(待审批状态)
|
# 创建新用户(待审批状态)
|
||||||
|
from backend.services.email_settings_service import EmailSettingsService
|
||||||
|
|
||||||
|
requires_approval = EmailSettingsService.is_registration_approval_required()
|
||||||
new_user = User(
|
new_user = User(
|
||||||
jwt_sub=jwt_sub,
|
jwt_sub=jwt_sub,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
authorization=pure_token, # 存储清理后的 token
|
authorization=pure_token, # 存储清理后的 token
|
||||||
jwt_exp=jwt_exp,
|
jwt_exp=jwt_exp,
|
||||||
role="user",
|
role="user",
|
||||||
is_approved=False, # 待审批
|
is_approved=not requires_approval,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
|
|||||||
@@ -159,6 +159,16 @@ class EmailService:
|
|||||||
|
|
||||||
return EmailService.send_email(admin_emails, subject, body_html)
|
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
|
@staticmethod
|
||||||
def notify_user_approved(user: User) -> bool:
|
def notify_user_approved(user: User) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -174,6 +184,9 @@ class EmailService:
|
|||||||
if user_email is None:
|
if user_email is None:
|
||||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
|
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
|
||||||
return False
|
return False
|
||||||
|
if not user.email_verified_at:
|
||||||
|
logger.info(f"用户 {user.alias} 邮箱未验证,跳过审批通知")
|
||||||
|
return False
|
||||||
|
|
||||||
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
|
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
|
||||||
body_html = render_email_template(
|
body_html = render_email_template(
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class EmailSettingsSnapshot:
|
|||||||
smtp_use_ssl: bool
|
smtp_use_ssl: bool
|
||||||
notify_token_expiring: bool
|
notify_token_expiring: bool
|
||||||
notify_check_in_success: bool
|
notify_check_in_success: bool
|
||||||
|
require_admin_approval_for_registration: bool
|
||||||
|
warn_unverified_email_before_approval: bool
|
||||||
has_smtp_sender_password: bool
|
has_smtp_sender_password: bool
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
@@ -46,6 +48,8 @@ class EmailSettingsService:
|
|||||||
smtp_use_ssl=settings.SMTP_USE_SSL,
|
smtp_use_ssl=settings.SMTP_USE_SSL,
|
||||||
notify_token_expiring=True,
|
notify_token_expiring=True,
|
||||||
notify_check_in_success=True,
|
notify_check_in_success=True,
|
||||||
|
require_admin_approval_for_registration=True,
|
||||||
|
warn_unverified_email_before_approval=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -60,6 +64,8 @@ class EmailSettingsService:
|
|||||||
smtp_use_ssl=settings.SMTP_USE_SSL,
|
smtp_use_ssl=settings.SMTP_USE_SSL,
|
||||||
notify_token_expiring=True,
|
notify_token_expiring=True,
|
||||||
notify_check_in_success=True,
|
notify_check_in_success=True,
|
||||||
|
require_admin_approval_for_registration=True,
|
||||||
|
warn_unverified_email_before_approval=True,
|
||||||
has_smtp_sender_password=bool(password),
|
has_smtp_sender_password=bool(password),
|
||||||
created_at=None,
|
created_at=None,
|
||||||
updated_at=None,
|
updated_at=None,
|
||||||
@@ -93,6 +99,10 @@ class EmailSettingsService:
|
|||||||
smtp_use_ssl=bool(row.smtp_use_ssl),
|
smtp_use_ssl=bool(row.smtp_use_ssl),
|
||||||
notify_token_expiring=bool(row.notify_token_expiring),
|
notify_token_expiring=bool(row.notify_token_expiring),
|
||||||
notify_check_in_success=bool(row.notify_check_in_success),
|
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),
|
has_smtp_sender_password=bool(password),
|
||||||
created_at=row.created_at,
|
created_at=row.created_at,
|
||||||
updated_at=row.updated_at,
|
updated_at=row.updated_at,
|
||||||
@@ -108,6 +118,8 @@ class EmailSettingsService:
|
|||||||
smtp_use_ssl=snapshot.smtp_use_ssl,
|
smtp_use_ssl=snapshot.smtp_use_ssl,
|
||||||
notify_token_expiring=snapshot.notify_token_expiring,
|
notify_token_expiring=snapshot.notify_token_expiring,
|
||||||
notify_check_in_success=snapshot.notify_check_in_success,
|
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,
|
has_smtp_sender_password=snapshot.has_smtp_sender_password,
|
||||||
created_at=snapshot.created_at,
|
created_at=snapshot.created_at,
|
||||||
updated_at=snapshot.updated_at,
|
updated_at=snapshot.updated_at,
|
||||||
@@ -132,6 +144,10 @@ class EmailSettingsService:
|
|||||||
row.smtp_use_ssl = payload.smtp_use_ssl
|
row.smtp_use_ssl = payload.smtp_use_ssl
|
||||||
row.notify_token_expiring = payload.notify_token_expiring
|
row.notify_token_expiring = payload.notify_token_expiring
|
||||||
row.notify_check_in_success = payload.notify_check_in_success
|
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:
|
if payload.clear_smtp_sender_password:
|
||||||
row.smtp_sender_password = ""
|
row.smtp_sender_password = ""
|
||||||
@@ -192,3 +208,29 @@ class EmailSettingsService:
|
|||||||
return EmailSettingsService._default_snapshot().notify_check_in_success
|
return EmailSettingsService._default_snapshot().notify_check_in_success
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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()
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
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 import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.models import User
|
from backend.models import User
|
||||||
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
|
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
|
||||||
@@ -26,6 +30,19 @@ def escape_like_pattern(text: str) -> str:
|
|||||||
class UserService:
|
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
|
@staticmethod
|
||||||
def create_user(user_data: UserCreate, db: Session) -> User:
|
def create_user(user_data: UserCreate, db: Session) -> User:
|
||||||
"""
|
"""
|
||||||
@@ -47,7 +64,7 @@ class UserService:
|
|||||||
user = User(
|
user = User(
|
||||||
jwt_sub=None, # NULL 表示未绑定 QQ
|
jwt_sub=None, # NULL 表示未绑定 QQ
|
||||||
alias=user_data.alias,
|
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",
|
role=user_data.role or "user",
|
||||||
is_approved=user_data.is_approved
|
is_approved=user_data.is_approved
|
||||||
if user_data.is_approved is not None
|
if user_data.is_approved is not None
|
||||||
@@ -192,7 +209,14 @@ class UserService:
|
|||||||
logger.info(f"管理员修改用户 {user.alias} (ID: {user_id}) 的密码")
|
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():
|
for key, value in update_data.items():
|
||||||
if key not in excluded_fields:
|
if key not in excluded_fields:
|
||||||
setattr(user, key, value)
|
setattr(user, key, value)
|
||||||
@@ -235,7 +259,10 @@ class UserService:
|
|||||||
|
|
||||||
# 更新邮箱
|
# 更新邮箱
|
||||||
if "email" in update_data:
|
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}")
|
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
|
||||||
|
|
||||||
# 更新密码
|
# 更新密码
|
||||||
@@ -262,6 +289,62 @@ class UserService:
|
|||||||
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
|
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
|
||||||
return user
|
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
|
@staticmethod
|
||||||
def delete_user(user_id: int, db: Session) -> bool:
|
def delete_user(user_id: int, db: Session) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
import type {
|
import type {
|
||||||
|
AdminApprovalResponse,
|
||||||
AdminStats,
|
AdminStats,
|
||||||
CheckInRecord,
|
CheckInRecord,
|
||||||
CheckInRecordStatus,
|
CheckInRecordStatus,
|
||||||
@@ -45,6 +46,8 @@ export const userApi = {
|
|||||||
me: () => apiClient.get<User>('/api/users/me'),
|
me: () => apiClient.get<User>('/api/users/me'),
|
||||||
status: () => apiClient.get<UserStatus>('/api/users/me/status'),
|
status: () => apiClient.get<UserStatus>('/api/users/me/status'),
|
||||||
tokenStatus: () => apiClient.get<TokenStatus>('/api/users/me/token_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: {
|
updateProfile: (payload: {
|
||||||
alias?: string
|
alias?: string
|
||||||
email?: string
|
email?: string
|
||||||
@@ -56,8 +59,12 @@ export const userApi = {
|
|||||||
apiClient.post<User>('/api/users', payload),
|
apiClient.post<User>('/api/users', payload),
|
||||||
update: (
|
update: (
|
||||||
userId: number,
|
userId: number,
|
||||||
payload: Partial<User> & { password?: string; reset_password?: boolean },
|
payload: Partial<User> & {
|
||||||
) => apiClient.put<User>(`/api/users/${userId}`, payload),
|
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}`),
|
delete: (userId: number) => apiClient.delete<void>(`/api/users/${userId}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +111,8 @@ export const templateApi = {
|
|||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
pendingUsers: () => apiClient.get<User[]>('/api/admin/users/pending'),
|
pendingUsers: () => apiClient.get<User[]>('/api/admin/users/pending'),
|
||||||
approveUser: (userId: number) =>
|
approveUser: (userId: number, payload: { allow_unverified_email?: boolean } = {}) =>
|
||||||
apiClient.post<{ success: boolean; message: string }>(`/api/admin/users/${userId}/approve`),
|
apiClient.post<AdminApprovalResponse>(`/api/admin/users/${userId}/approve`, payload),
|
||||||
rejectUser: (userId: number) =>
|
rejectUser: (userId: number) =>
|
||||||
apiClient.delete<{ success: boolean; message: string }>(`/api/admin/users/${userId}/reject`),
|
apiClient.delete<{ success: boolean; message: string }>(`/api/admin/users/${userId}/reject`),
|
||||||
stats: () => apiClient.get<AdminStats>('/api/admin/stats'),
|
stats: () => apiClient.get<AdminStats>('/api/admin/stats'),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface User {
|
|||||||
is_approved: boolean
|
is_approved: boolean
|
||||||
jwt_exp: string
|
jwt_exp: string
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
email_verified?: boolean
|
||||||
|
email_verified_at?: string | null
|
||||||
has_password?: boolean
|
has_password?: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
@@ -35,6 +37,8 @@ export interface AuthUserPayload {
|
|||||||
is_approved?: boolean
|
is_approved?: boolean
|
||||||
jwt_exp?: string
|
jwt_exp?: string
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
email_verified?: boolean
|
||||||
|
email_verified_at?: string | null
|
||||||
has_password?: boolean
|
has_password?: boolean
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
@@ -210,6 +214,8 @@ export interface EmailNotificationSettings {
|
|||||||
smtp_use_ssl: boolean
|
smtp_use_ssl: boolean
|
||||||
notify_token_expiring: boolean
|
notify_token_expiring: boolean
|
||||||
notify_check_in_success: boolean
|
notify_check_in_success: boolean
|
||||||
|
require_admin_approval_for_registration: boolean
|
||||||
|
warn_unverified_email_before_approval: boolean
|
||||||
has_smtp_sender_password: boolean
|
has_smtp_sender_password: boolean
|
||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
@@ -222,10 +228,20 @@ export interface EmailNotificationSettingsUpdate {
|
|||||||
smtp_use_ssl: boolean
|
smtp_use_ssl: boolean
|
||||||
notify_token_expiring: boolean
|
notify_token_expiring: boolean
|
||||||
notify_check_in_success: boolean
|
notify_check_in_success: boolean
|
||||||
|
require_admin_approval_for_registration: boolean
|
||||||
|
warn_unverified_email_before_approval: boolean
|
||||||
smtp_sender_password?: string
|
smtp_sender_password?: string
|
||||||
clear_smtp_sender_password?: boolean
|
clear_smtp_sender_password?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminApprovalResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
user_id?: number
|
||||||
|
requires_override?: boolean
|
||||||
|
warning_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CronValidation {
|
export interface CronValidation {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ function userFromLogin(payload: LoginResponse): User | null {
|
|||||||
is_approved: raw?.is_approved ?? payload.is_approved ?? false,
|
is_approved: raw?.is_approved ?? payload.is_approved ?? false,
|
||||||
jwt_exp: raw?.jwt_exp ?? '',
|
jwt_exp: raw?.jwt_exp ?? '',
|
||||||
email: raw?.email ?? null,
|
email: raw?.email ?? null,
|
||||||
|
email_verified: raw?.email_verified ?? false,
|
||||||
|
email_verified_at: raw?.email_verified_at ?? null,
|
||||||
has_password: raw?.has_password,
|
has_password: raw?.has_password,
|
||||||
created_at: raw?.created_at ?? new Date().toISOString(),
|
created_at: raw?.created_at ?? new Date().toISOString(),
|
||||||
updated_at: raw?.updated_at ?? null,
|
updated_at: raw?.updated_at ?? null,
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RefreshCw } from 'lucide-vue-next'
|
import { MailCheck, RefreshCw, Send } from 'lucide-vue-next'
|
||||||
import { ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { userApi } from '@/api'
|
import { userApi } from '@/api'
|
||||||
import { useAuth } from '@/app/auth'
|
import { useAuth } from '@/app/auth'
|
||||||
import { useRouter } from '@/app/router'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
|
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
|
||||||
|
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const sendingCode = ref(false)
|
||||||
|
const verifying = ref(false)
|
||||||
const error = ref('')
|
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() {
|
async function refresh() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -28,6 +37,37 @@ async function refresh() {
|
|||||||
loading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,6 +79,9 @@ async function refresh() {
|
|||||||
当前账号
|
当前账号
|
||||||
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录,但还需要管理员审批后才能访问工作台。
|
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录,但还需要管理员审批后才能访问工作台。
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-3 text-sm font-medium text-[var(--tone-warning-fg)]">
|
||||||
|
请填写并验证邮箱。只有邮箱完成验证后账号才会进入正常审批流程;未验证的待审批账号可能会在清理窗口后自动吊销。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<dl class="mt-5 grid gap-3 sm:grid-cols-2">
|
<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>
|
<dt class="text-xs text-muted-foreground">审批状态</dt>
|
||||||
<dd class="mt-1 text-sm font-medium">待审批</dd>
|
<dd class="mt-1 text-sm font-medium">待审批</dd>
|
||||||
</div>
|
</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>
|
</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']">
|
<div v-if="error" :class="[alertClass.danger, 'mt-4']">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const form = reactive({
|
|||||||
smtp_use_ssl: true,
|
smtp_use_ssl: true,
|
||||||
notify_token_expiring: true,
|
notify_token_expiring: true,
|
||||||
notify_check_in_success: true,
|
notify_check_in_success: true,
|
||||||
|
require_admin_approval_for_registration: true,
|
||||||
|
warn_unverified_email_before_approval: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const passwordState = computed(() => {
|
const passwordState = computed(() => {
|
||||||
@@ -47,6 +49,8 @@ function hydrate(next: EmailNotificationSettings) {
|
|||||||
form.smtp_use_ssl = next.smtp_use_ssl
|
form.smtp_use_ssl = next.smtp_use_ssl
|
||||||
form.notify_token_expiring = next.notify_token_expiring
|
form.notify_token_expiring = next.notify_token_expiring
|
||||||
form.notify_check_in_success = next.notify_check_in_success
|
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() {
|
async function load() {
|
||||||
@@ -63,6 +67,12 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (!form.require_admin_approval_for_registration) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
'关闭管理员审批后,新注册用户可能绕过人工审核直接进入系统。确认关闭管理员审批?',
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
savedMessage.value = ''
|
savedMessage.value = ''
|
||||||
@@ -74,6 +84,8 @@ async function save() {
|
|||||||
smtp_use_ssl: form.smtp_use_ssl,
|
smtp_use_ssl: form.smtp_use_ssl,
|
||||||
notify_token_expiring: form.notify_token_expiring,
|
notify_token_expiring: form.notify_token_expiring,
|
||||||
notify_check_in_success: form.notify_check_in_success,
|
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,
|
smtp_sender_password: form.smtp_sender_password || undefined,
|
||||||
clear_smtp_sender_password: form.clear_smtp_sender_password,
|
clear_smtp_sender_password: form.clear_smtp_sender_password,
|
||||||
})
|
})
|
||||||
@@ -205,6 +217,34 @@ onMounted(load)
|
|||||||
<span class="text-sm text-muted-foreground">只影响过期前提醒,不影响已过期通知。</span>
|
<span class="text-sm text-muted-foreground">只影响过期前提醒,不影响已过期通知。</span>
|
||||||
</label>
|
</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">
|
<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">
|
<span class="flex items-center justify-between gap-3 text-sm font-medium">
|
||||||
打卡成功通知
|
打卡成功通知
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, Save, Search, Trash2, UserPlus } from 'lucide-vue-next'
|
import { Check, Save, Search, Trash2, UserPlus } from 'lucide-vue-next'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
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 StateBlock from '@/components/StateBlock.vue'
|
||||||
import {
|
import {
|
||||||
alertClass,
|
alertClass,
|
||||||
@@ -27,6 +27,12 @@ const form = reactive({
|
|||||||
is_approved: true,
|
is_approved: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function requiresUnverifiedEmailOverride(
|
||||||
|
result: User | AdminApprovalResponse,
|
||||||
|
): result is AdminApprovalResponse {
|
||||||
|
return 'requires_override' in result && result.warning_code === 'UNVERIFIED_EMAIL'
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -40,7 +46,12 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function approve(userId: number) {
|
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()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +92,12 @@ async function save() {
|
|||||||
if (editingId.value === 'new') {
|
if (editingId.value === 'new') {
|
||||||
await userApi.create(payload)
|
await userApi.create(payload)
|
||||||
} else if (typeof editingId.value === 'number') {
|
} 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
|
editingId.value = null
|
||||||
await load()
|
await load()
|
||||||
@@ -145,6 +161,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
<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 || '未设置邮箱' }}</span>
|
||||||
|
<span>{{ user.email_verified ? '邮箱已验证' : '邮箱未验证' }}</span>
|
||||||
<span>{{ formatDateTime(user.created_at) }}</span>
|
<span>{{ formatDateTime(user.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from backend.migration_steps.email_notification_settings import (
|
|||||||
apply as apply_email_notification_settings,
|
apply as apply_email_notification_settings,
|
||||||
)
|
)
|
||||||
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
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:
|
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",
|
"2026050401_add_account_lockout",
|
||||||
"2026050402_add_task_thread_id",
|
"2026050402_add_task_thread_id",
|
||||||
"2026050501_add_email_notification_settings",
|
"2026050501_add_email_notification_settings",
|
||||||
|
"2026050601_add_user_email_verification",
|
||||||
]
|
]
|
||||||
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
|
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
|
||||||
"backend.migration_steps.account_lockout",
|
"backend.migration_steps.account_lockout",
|
||||||
"backend.migration_steps.task_thread_id",
|
"backend.migration_steps.task_thread_id",
|
||||||
"backend.migration_steps.email_notification_settings",
|
"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",
|
"smtp_use_ssl",
|
||||||
"notify_token_expiring",
|
"notify_token_expiring",
|
||||||
"notify_check_in_success",
|
"notify_check_in_success",
|
||||||
|
"require_admin_approval_for_registration",
|
||||||
|
"warn_unverified_email_before_approval",
|
||||||
} <= columns
|
} <= 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:
|
def test_account_lockout_migration_adds_missing_user_fields() -> None:
|
||||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
|||||||
assert updated.smtp_sender_password == "new-secret"
|
assert updated.smtp_sender_password == "new-secret"
|
||||||
assert updated.notify_token_expiring is False
|
assert updated.notify_token_expiring is False
|
||||||
assert updated.notify_check_in_success 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(
|
EmailSettingsService.update_settings(
|
||||||
session,
|
session,
|
||||||
@@ -84,6 +86,8 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
|||||||
smtp_use_ssl=True,
|
smtp_use_ssl=True,
|
||||||
notify_token_expiring=False,
|
notify_token_expiring=False,
|
||||||
notify_check_in_success=False,
|
notify_check_in_success=False,
|
||||||
|
require_admin_approval_for_registration=False,
|
||||||
|
warn_unverified_email_before_approval=False,
|
||||||
clear_smtp_sender_password=True,
|
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)
|
cleared = EmailSettingsService.get_snapshot(session)
|
||||||
assert cleared.smtp_sender_password in ("", None)
|
assert cleared.smtp_sender_password in ("", None)
|
||||||
assert cleared.has_smtp_sender_password is False
|
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()
|
session.close()
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
@@ -195,3 +201,14 @@ def test_email_settings_update_validates_sender_email() -> None:
|
|||||||
notify_token_expiring=True,
|
notify_token_expiring=True,
|
||||||
notify_check_in_success=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()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
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)
|
monkeypatch.setattr(EmailService, "send_email", fake_send)
|
||||||
|
|
||||||
assert (
|
user = User(alias="Alice", email="alice@example.test")
|
||||||
EmailService.notify_user_approved(User(alias="Alice", email="alice@example.test")) is True
|
user.email_verified_at = datetime.now(timezone.utc)
|
||||||
)
|
assert EmailService.notify_user_approved(user) is True
|
||||||
|
|
||||||
assert sent["to_emails"] == ["alice@example.test"]
|
assert sent["to_emails"] == ["alice@example.test"]
|
||||||
assert "账户审批通过" in str(sent["subject"])
|
assert "账户审批通过" in str(sent["subject"])
|
||||||
|
|||||||
@@ -65,6 +65,28 @@ def test_frontend_admin_api_covers_email_settings() -> None:
|
|||||||
assert "/api/admin/email_settings" in api
|
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:
|
def test_frontend_replaces_starter_component() -> None:
|
||||||
app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8")
|
app = (SRC_ROOT / "App.vue").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user