Compare commits

4 Commits

Author SHA1 Message Date
Cheng Zhou b25055775e 完善邮件验证与密码重置安全流程 2026-06-30 09:44:24 +08:00
Cheng Zhou 6c2bcc59b2 调整账号注册与删除流程 2026-06-29 16:25:04 +08:00
Cheng Zhou e242e067f0 feat(ui): 美化忘记密码页面视觉风格
- 重构整体布局为极光渐变背景 + 居中毛玻璃卡片设计
- 新增浮动极光球光效(aurora-1/2/3)与旋转几何圆环装饰动效
- 卡片采用半透明磨砂玻璃(backdrop-filter: blur(40px)),阴影层次丰富
- 步骤指引改为带渐变编号圆圈的卡片列表,悬停有微动效
- 联系方式区块增加图标 + 悬停高亮交互
- 返回登录按钮增加光泽扫光动画与上浮效果
- 完整适配移动端响应式(max-width: 640px)
2026-06-29 11:31:10 +08:00
Cheng Zhou c7ca97a6d6 feat(ui): 优化登录与注册页面视觉风格
- 统一登录页(Login.vue)左侧品牌文案,移除虚假 AI 宣传,
  改为准确描述系统实际功能:项目全流程管理、合规核查与
  数据溯源、偏差与安全事件管理
- 重构注册页(Register.vue)为与登录页一致的明亮极光双栏布局:
  · 左侧:蓝紫渐变品牌面板 + 特性卡片 + 入场动效
  · 右侧:纯白背景 + 下划线风格输入框 + 蓝色 #2563eb 主色调
  · 步骤进度条、按钮、复选框配色全部对齐登录页风格
  · 小屏(< 900px)自动隐藏左侧品牌面板,右侧全屏适配
2026-06-29 11:22:56 +08:00
35 changed files with 6259 additions and 1997 deletions
@@ -0,0 +1,94 @@
"""add email verification settings
Revision ID: 20260629_01
Revises: 20260608_01
Create Date: 2026-06-29 11:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260629_01"
down_revision: Union[str, None] = "20260608_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _uuid_column() -> sa.Column:
return sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
nullable=False,
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
smtp_security = postgresql.ENUM("NONE", "SSL", "STARTTLS", name="smtp_security", create_type=False)
email_purpose = postgresql.ENUM("REGISTER", "PASSWORD_RESET", name="email_verification_purpose", create_type=False)
smtp_security.create(bind, checkfirst=True)
email_purpose.create(bind, checkfirst=True)
if not _table_exists(inspector, "system_email_settings"):
op.create_table(
"system_email_settings",
_uuid_column(),
sa.Column("smtp_host", sa.String(length=255), nullable=False),
sa.Column("smtp_port", sa.Integer(), nullable=False),
sa.Column("smtp_security", smtp_security, server_default="SSL", nullable=False),
sa.Column("smtp_username", sa.String(length=255), nullable=False),
sa.Column("smtp_password_encrypted", sa.Text(), nullable=True),
sa.Column("sender_email", sa.String(length=255), nullable=False),
sa.Column("sender_name", sa.String(length=255), nullable=True),
sa.Column("allowed_register_domain", sa.String(length=255), nullable=False),
sa.Column("verification_code_ttl_minutes", sa.Integer(), nullable=False),
sa.Column("send_cooldown_seconds", sa.Integer(), nullable=False),
sa.Column("max_verify_attempts", sa.Integer(), nullable=False),
sa.Column("updated_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
)
if not _table_exists(inspector, "email_verification_codes"):
op.create_table(
"email_verification_codes",
_uuid_column(),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("purpose", email_purpose, server_default="REGISTER", nullable=False),
sa.Column("code_hash", sa.String(length=255), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index(
"ix_email_verification_codes_email",
"email_verification_codes",
["email"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "email_verification_codes"):
op.drop_index("ix_email_verification_codes_email", table_name="email_verification_codes")
op.drop_table("email_verification_codes")
if _table_exists(inspector, "system_email_settings"):
op.drop_table("system_email_settings")
sa.Enum("REGISTER", "PASSWORD_RESET", name="email_verification_purpose").drop(bind, checkfirst=True)
sa.Enum("NONE", "SSL", "STARTTLS", name="smtp_security").drop(bind, checkfirst=True)
@@ -0,0 +1,40 @@
"""default multiple register email domains
Revision ID: 20260629_02
Revises: 20260629_01
Create Date: 2026-06-29 12:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260629_02"
down_revision: Union[str, None] = "20260629_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "system_email_settings" in inspector.get_table_names():
op.execute(
"UPDATE system_email_settings "
"SET allowed_register_domain = 'huapont.cn,qq.com' "
"WHERE allowed_register_domain IN ('huapont.cn', '@huapont.cn')"
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "system_email_settings" in inspector.get_table_names():
op.execute(
"UPDATE system_email_settings "
"SET allowed_register_domain = 'huapont.cn' "
"WHERE allowed_register_domain = 'huapont.cn,qq.com'"
)
@@ -0,0 +1,123 @@
"""email settings per register domain
Revision ID: 20260629_03
Revises: 20260629_02
Create Date: 2026-06-29 14:20:00.000000
"""
from typing import Sequence, Union
import uuid
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260629_03"
down_revision: Union[str, None] = "20260629_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _normalize_domains(value: str | None) -> list[str]:
domains: list[str] = []
for item in (value or "huapont.cn,qq.com").replace("", ",").split(","):
domain = item.strip().lower().lstrip("@")
if domain and domain not in domains:
domains.append(domain)
return domains or ["huapont.cn", "qq.com"]
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "system_email_settings"):
return
if not _column_exists(inspector, "system_email_settings", "register_domain"):
op.add_column("system_email_settings", sa.Column("register_domain", sa.String(length=255), nullable=True))
rows = bind.execute(sa.text("SELECT * FROM system_email_settings ORDER BY created_at ASC")).mappings().all()
for row in rows:
domains = _normalize_domains(row.get("allowed_register_domain"))
primary_domain = domains[0]
bind.execute(
sa.text(
"UPDATE system_email_settings "
"SET register_domain = :register_domain, allowed_register_domain = :register_domain "
"WHERE id = :id"
),
{"register_domain": primary_domain, "id": row["id"]},
)
for domain in domains[1:]:
exists = bind.execute(
sa.text("SELECT 1 FROM system_email_settings WHERE register_domain = :register_domain"),
{"register_domain": domain},
).first()
if exists:
continue
bind.execute(
sa.text(
"""
INSERT INTO system_email_settings (
id, register_domain, smtp_host, smtp_port, smtp_security, smtp_username,
smtp_password_encrypted, sender_email, sender_name, allowed_register_domain,
verification_code_ttl_minutes, send_cooldown_seconds, max_verify_attempts,
updated_by, created_at, updated_at
) VALUES (
:id, :register_domain, :smtp_host, :smtp_port, :smtp_security, :smtp_username,
:smtp_password_encrypted, :sender_email, :sender_name, :allowed_register_domain,
:verification_code_ttl_minutes, :send_cooldown_seconds, :max_verify_attempts,
:updated_by, now(), now()
)
"""
),
{
"id": uuid.uuid4(),
"register_domain": domain,
"smtp_host": row["smtp_host"],
"smtp_port": row["smtp_port"],
"smtp_security": row["smtp_security"],
"smtp_username": row["smtp_username"],
"smtp_password_encrypted": row["smtp_password_encrypted"],
"sender_email": row["sender_email"],
"sender_name": row["sender_name"],
"allowed_register_domain": domain,
"verification_code_ttl_minutes": row["verification_code_ttl_minutes"],
"send_cooldown_seconds": row["send_cooldown_seconds"],
"max_verify_attempts": row["max_verify_attempts"],
"updated_by": row["updated_by"],
},
)
op.alter_column("system_email_settings", "register_domain", existing_type=sa.String(length=255), nullable=False)
op.create_unique_constraint(
"uq_system_email_settings_register_domain",
"system_email_settings",
["register_domain"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "system_email_settings"):
return
uniques = {item["name"] for item in inspector.get_unique_constraints("system_email_settings")}
if "uq_system_email_settings_register_domain" in uniques:
op.drop_constraint(
"uq_system_email_settings_register_domain",
"system_email_settings",
type_="unique",
)
if _column_exists(inspector, "system_email_settings", "register_domain"):
op.drop_column("system_email_settings", "register_domain")
@@ -0,0 +1,26 @@
"""add password reset email purpose
Revision ID: 20260629_04
Revises: 20260629_03
Create Date: 2026-06-29 16:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260629_04"
down_revision: Union[str, None] = "20260629_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET'")
def downgrade() -> None:
# PostgreSQL enum values cannot be removed safely without recreating the type.
pass
@@ -0,0 +1,26 @@
"""add password reset link email purpose
Revision ID: 20260630_01
Revises: 20260629_04
Create Date: 2026-06-30 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260630_01"
down_revision: Union[str, None] = "20260629_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET_LINK'")
def downgrade() -> None:
# PostgreSQL enum values cannot be removed safely without recreating the type.
pass
@@ -0,0 +1,76 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles
from app.schemas.email_settings import (
EmailDomainCreateRequest,
EmailSettingsListResponse,
EmailSettingsRead,
EmailSettingsUpdate,
EmailTestRequest,
)
from app.services import email_service
router = APIRouter(prefix="/email-settings")
@router.get("/", response_model=EmailSettingsListResponse)
async def read_email_settings(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsListResponse:
items = [email_service.to_email_settings_read(row) for row in await email_service.list_email_settings(db)]
return EmailSettingsListResponse(items=items, total=len(items))
@router.post("/", response_model=EmailSettingsRead)
async def create_email_domain(
payload: EmailDomainCreateRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
settings_row = await email_service.create_email_domain(db, payload.register_domain, updated_by=current_user.id)
return email_service.to_email_settings_read(settings_row)
@router.get("/{register_domain}", response_model=EmailSettingsRead)
async def read_email_domain_settings(
register_domain: str,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
return email_service.to_email_settings_read(await email_service.get_email_settings(db, register_domain))
@router.put("/{register_domain}", response_model=EmailSettingsRead)
async def update_email_settings(
register_domain: str,
payload: EmailSettingsUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
settings_row = await email_service.upsert_email_settings(db, register_domain, payload, updated_by=current_user.id)
return email_service.to_email_settings_read(settings_row)
@router.delete("/{register_domain}")
async def delete_email_domain(
register_domain: str,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
):
await email_service.delete_email_domain(db, register_domain)
return {"message": "邮箱后缀已删除"}
@router.post("/{register_domain}/test")
async def test_email_settings(
register_domain: str,
payload: EmailTestRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
):
await email_service.send_test_email(db, register_domain, str(payload.recipient_email))
return {"message": "测试邮件已发送"}
-62
View File
@@ -1,62 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, is_system_admin, require_roles
from app.crud import user as user_crud
from app.models.user import User, UserStatus
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
router = APIRouter(prefix="/users")
@router.get("/", response_model=AdminUserListResponse)
async def list_users_for_review(
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> AdminUserListResponse:
users = await user_crud.list_users_by_status(db, status=status_filter)
return AdminUserListResponse(items=list(users), total=len(users))
async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
user = await user_crud.get_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if is_system_admin(user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
return user
@router.post("/{user_id}/approve", response_model=UserResponse)
async def approve_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse:
if review.action != "approve":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.approve_user(db, user, admin_id=current_user.id)
return user
@router.post("/{user_id}/reject", response_model=UserResponse)
async def reject_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse:
if review.action not in ("reject", "approve"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.reject_user(db, user, admin_id=current_user.id)
return user
+109 -4
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import File, UploadFile from fastapi import File, UploadFile
from pydantic import BaseModel, Field from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path from pathlib import Path
import uuid import uuid
@@ -12,7 +12,19 @@ from app.core.security import create_access_token, decode_token_allow_expired, o
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud from app.crud import user as user_crud
from app.models.user import UserStatus from app.models.user import UserStatus
from app.schemas.email_settings import (
EmailCodeResponse,
EmailCodeVerifyResponse,
PasswordResetCodeVerifyRequest,
PasswordResetCodeVerifyResponse,
PasswordResetLinkSendRequest,
PasswordResetRequest,
PasswordResetTokenRequest,
RegisterEmailCodeSendRequest,
RegisterEmailCodeVerifyRequest,
)
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from app.services import email_service
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -39,6 +51,14 @@ class ExtendResponse(BaseModel):
expiresAt: datetime expiresAt: datetime
class EmailAvailabilityResponse(BaseModel):
available: bool
class EmailDomainsResponse(BaseModel):
items: list[str]
router = APIRouter() router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True) AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
@@ -108,6 +128,90 @@ def ensure_user_active(db_user) -> None:
) )
@router.get("/email-domains", response_model=EmailDomainsResponse)
async def read_email_domains(
db: AsyncSession = Depends(get_db_session),
) -> EmailDomainsResponse:
rows = await email_service.list_email_settings(db)
return EmailDomainsResponse(items=[row.register_domain for row in rows])
@router.get("/register/email-availability", response_model=EmailAvailabilityResponse)
async def check_register_email_availability(
email: EmailStr = Query(...),
db: AsyncSession = Depends(get_db_session),
) -> EmailAvailabilityResponse:
existing = await user_crud.get_by_email(db, str(email))
return EmailAvailabilityResponse(available=existing is None)
@router.post("/register/email-code/send", response_model=EmailCodeResponse)
async def send_register_email_code(
payload: RegisterEmailCodeSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_register_code(db, str(payload.email))
return EmailCodeResponse(message="验证码已发送")
@router.post("/register/email-code/verify", response_model=EmailCodeVerifyResponse)
async def verify_register_email_code(
payload: RegisterEmailCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeVerifyResponse:
await email_service.verify_register_code(db, str(payload.email), payload.code)
return EmailCodeVerifyResponse(verified=True)
@router.post("/password-reset/email-code/send", response_model=EmailCodeResponse)
async def send_password_reset_email_code(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_code(db, str(payload.email))
return EmailCodeResponse(message="验证码发送成功,请查收邮箱")
@router.post("/password-reset/email-code/verify", response_model=PasswordResetCodeVerifyResponse)
async def verify_password_reset_email_code(
payload: PasswordResetCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> PasswordResetCodeVerifyResponse:
reset_token = await email_service.verify_password_reset_code(db, str(payload.email), payload.code)
return PasswordResetCodeVerifyResponse(verified=True, reset_token=reset_token)
@router.post("/password-reset-link/send", response_model=EmailCodeResponse)
async def send_password_reset_link(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_link(
db,
str(payload.email),
frontend_origin=settings.FRONTEND_PUBLIC_URL,
)
return EmailCodeResponse(message="如果账号存在,重置链接已发送")
@router.post("/password-reset", response_model=EmailCodeResponse)
async def reset_password(
payload: PasswordResetRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_code(db, str(payload.email), payload.code, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@router.post("/password-reset-link", response_model=EmailCodeResponse)
async def reset_password_with_link(
payload: PasswordResetTokenRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_token(db, payload.token, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@router.post("/register", status_code=status.HTTP_201_CREATED) @router.post("/register", status_code=status.HTTP_201_CREATED)
async def register( async def register(
payload: UserRegisterRequest, payload: UserRegisterRequest,
@@ -116,8 +220,9 @@ async def register(
existing = await user_crud.get_by_email(db, payload.email) existing = await user_crud.get_by_email(db, payload.email)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await user_crud.create_pending_user(db, payload) await email_service.ensure_register_email_verified(db, str(payload.email))
return {"message": "注册成功,等待管理员审核"} await user_crud.create_registered_user(db, payload)
return {"message": "注册成功,请登录"}
@router.get("/login-key", response_model=LoginKeyResponse) @router.get("/login-key", response_model=LoginKeyResponse)
+2 -2
View File
@@ -1,11 +1,11 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"]) api_router.include_router(admin_email_settings.router, prefix="/admin", tags=["admin"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"]) api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"]) api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
+14 -1
View File
@@ -1,6 +1,7 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, is_system_admin, require_roles from app.core.deps import get_db_session, is_system_admin, require_roles
@@ -97,4 +98,16 @@ async def delete_user(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除", detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除",
) )
await user_crud.delete_user(db, db_user) if await user_crud.user_has_retained_history(db, db_user.id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号已有审计或权限访问记录,请停用账号以保留历史追溯",
)
try:
await user_crud.delete_user(db, db_user)
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号已有业务记录,请停用账号以保留历史追溯",
) from exc
+2
View File
@@ -24,6 +24,8 @@ class Settings(BaseSettings):
LOGIN_RSA_KEY_ID: str = "default" LOGIN_RSA_KEY_ID: str = "default"
LOGIN_CHALLENGE_TTL_SECONDS: int = 120 LOGIN_CHALLENGE_TTL_SECONDS: int = 120
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000 LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
SETTINGS_ENCRYPTION_KEY: Optional[str] = None
FRONTEND_PUBLIC_URL: str = "http://localhost:8888"
IP2REGION_XDB_PATH: Optional[str] = None IP2REGION_XDB_PATH: Optional[str] = None
IP2REGION_IPV6_XDB_PATH: Optional[str] = None IP2REGION_IPV6_XDB_PATH: Optional[str] = None
+17 -24
View File
@@ -13,6 +13,8 @@ from app.core.config import (
PROTECTED_ADMIN_FULL_NAME, PROTECTED_ADMIN_FULL_NAME,
) )
from app.core.security import hash_password from app.core.security import hash_password
from app.models.audit_log import AuditLog
from app.models.permission_access_log import PermissionAccessLog
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
from app.models.user import User, UserStatus from app.models.user import User, UserStatus
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
@@ -53,8 +55,8 @@ async def create_user(
return user return user
async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User: async def create_registered_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING) return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.ACTIVE)
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User: async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
@@ -150,6 +152,19 @@ async def count_active_admins(db: AsyncSession) -> int:
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
async def user_has_retained_history(db: AsyncSession, user_id: uuid.UUID) -> bool:
audit_count = await db.scalar(
select(func.count()).select_from(AuditLog).where(AuditLog.operator_id == user_id)
)
if int(audit_count or 0) > 0:
return True
permission_log_count = await db.scalar(
select(func.count()).select_from(PermissionAccessLog).where(PermissionAccessLog.user_id == user_id)
)
return int(permission_log_count or 0) > 0
async def list_users_by_status( async def list_users_by_status(
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100 db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
) -> Sequence[User]: ) -> Sequence[User]:
@@ -189,25 +204,3 @@ async def delete_user(db: AsyncSession, user: User) -> None:
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id)) await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
await db.delete(user) await db.delete(user)
await db.commit() await db.commit()
async def approve_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
await db.execute(
update(User)
.where(User.id == user.id)
.values(status=UserStatus.ACTIVE, approved_by=admin_id, approved_at=func.now())
)
await db.commit()
await db.refresh(user)
return user
async def reject_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
await db.execute(
update(User)
.where(User.id == user.id)
.values(status=UserStatus.REJECTED, approved_by=admin_id, approved_at=func.now())
)
await db.commit()
await db.refresh(user)
return user
+1
View File
@@ -42,3 +42,4 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401 from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401 from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
from app.models.security_access_log import SecurityAccessLog # noqa: F401 from app.models.security_access_log import SecurityAccessLog # noqa: F401
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class SmtpSecurity(str, enum.Enum):
NONE = "NONE"
SSL = "SSL"
STARTTLS = "STARTTLS"
class EmailVerificationPurpose(str, enum.Enum):
REGISTER = "REGISTER"
PASSWORD_RESET = "PASSWORD_RESET"
PASSWORD_RESET_LINK = "PASSWORD_RESET_LINK"
class SystemEmailSettings(Base):
__tablename__ = "system_email_settings"
__table_args__ = (UniqueConstraint("register_domain", name="uq_system_email_settings_register_domain"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
register_domain: Mapped[str] = mapped_column(String(255), nullable=False, default="huapont.cn")
smtp_host: Mapped[str] = mapped_column(String(255), nullable=False, default="")
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False)
smtp_security: Mapped[SmtpSecurity] = mapped_column(
Enum(SmtpSecurity, name="smtp_security"),
nullable=False,
default=SmtpSecurity.SSL,
server_default=SmtpSecurity.SSL.value,
)
smtp_username: Mapped[str] = mapped_column(String(255), nullable=False, default="")
smtp_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sender_email: Mapped[str] = mapped_column(String(255), nullable=False, default="")
sender_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
allowed_register_domain: Mapped[str] = mapped_column(String(255), nullable=False, default="huapont.cn")
verification_code_ttl_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
send_cooldown_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
max_verify_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
updated_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
purpose: Mapped[EmailVerificationPurpose] = mapped_column(
Enum(EmailVerificationPurpose, name="email_verification_purpose"),
nullable=False,
default=EmailVerificationPurpose.REGISTER,
server_default=EmailVerificationPurpose.REGISTER.value,
)
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
SmtpSecurityValue = Literal["NONE", "SSL", "STARTTLS"]
class EmailSettingsRead(BaseModel):
register_domain: str = "huapont.cn"
smtp_host: str = ""
smtp_port: int = 465
smtp_security: SmtpSecurityValue = "SSL"
smtp_username: str = ""
smtp_password_configured: bool = False
sender_email: str = ""
sender_name: Optional[str] = "CTMS 系统"
allowed_register_domain: str = "huapont.cn,qq.com"
verification_code_ttl_minutes: int = 10
send_cooldown_seconds: int = 60
max_verify_attempts: int = 5
updated_at: Optional[datetime] = None
class EmailSettingsUpdate(BaseModel):
smtp_host: str = Field(default="", max_length=255)
smtp_port: int = Field(ge=1, le=65535)
smtp_security: SmtpSecurityValue = "SSL"
smtp_username: str = Field(default="", max_length=255)
smtp_password: Optional[str] = Field(default=None, max_length=500)
sender_email: str = Field(default="", max_length=255)
sender_name: Optional[str] = Field(default="CTMS 系统", max_length=255)
allowed_register_domain: str = Field(default="", max_length=255)
verification_code_ttl_minutes: int = Field(default=10, ge=1, le=60)
send_cooldown_seconds: int = Field(default=60, ge=10, le=3600)
max_verify_attempts: int = Field(default=5, ge=1, le=20)
@field_validator("allowed_register_domain")
@classmethod
def normalize_domain(cls, value: str) -> str:
domains = []
for item in value.replace("", ",").split(","):
domain = item.strip().lower().lstrip("@")
if domain and domain not in domains:
domains.append(domain)
if not domains:
raise ValueError("至少配置一个允许注册邮箱域名")
return ",".join(domains)
class EmailTestRequest(BaseModel):
recipient_email: EmailStr
class EmailDomainCreateRequest(BaseModel):
register_domain: str = Field(min_length=1, max_length=255)
@field_validator("register_domain")
@classmethod
def normalize_domain(cls, value: str) -> str:
return value.strip().lower().lstrip("@")
class EmailSettingsListResponse(BaseModel):
items: list[EmailSettingsRead]
total: int
class RegisterEmailCodeSendRequest(BaseModel):
email: EmailStr
class RegisterEmailCodeVerifyRequest(BaseModel):
email: EmailStr
code: str = Field(min_length=4, max_length=12)
class PasswordResetLinkSendRequest(BaseModel):
email: EmailStr
class PasswordResetRequest(BaseModel):
email: EmailStr
code: str = Field(min_length=4, max_length=12)
password: str = Field(min_length=8, max_length=72)
@field_validator("password")
@classmethod
def validate_password_strength(cls, value: str) -> str:
has_letter = any(ch.isalpha() for ch in value)
has_number = any(ch.isdigit() for ch in value)
if not has_letter or not has_number:
raise ValueError("密码需至少 8 位且包含字母和数字")
return value
class PasswordResetCodeVerifyRequest(BaseModel):
email: EmailStr
code: str = Field(min_length=4, max_length=12)
class PasswordResetTokenRequest(BaseModel):
token: str = Field(min_length=20, max_length=200)
password: str = Field(min_length=8, max_length=72)
@field_validator("password")
@classmethod
def validate_password_strength(cls, value: str) -> str:
has_letter = any(ch.isalpha() for ch in value)
has_number = any(ch.isdigit() for ch in value)
if not has_letter or not has_number:
raise ValueError("密码需至少 8 位且包含字母和数字")
return value
class EmailCodeResponse(BaseModel):
message: str
class EmailCodeVerifyResponse(BaseModel):
verified: bool
class PasswordResetCodeVerifyResponse(BaseModel):
verified: bool
reset_token: str
-9
View File
@@ -78,15 +78,6 @@ class UserResponse(UserRead):
pass pass
class UserAdminReviewRequest(BaseModel):
action: Literal["approve", "reject"]
class AdminUserListResponse(BaseModel):
items: list[UserResponse]
total: int
class UserSelfUpdate(_PasswordValidator): class UserSelfUpdate(_PasswordValidator):
full_name: Optional[str] = None full_name: Optional[str] = None
clinical_department: Optional[str] = None clinical_department: Optional[str] = None
+563
View File
@@ -0,0 +1,563 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import random
import secrets
import smtplib
import ssl
import uuid
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import formataddr
from cryptography.fernet import Fernet, InvalidToken
from fastapi import HTTPException, status
from sqlalchemy import delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import hash_password, verify_password
from app.crud import user as user_crud
from app.models.email_settings import EmailVerificationCode, EmailVerificationPurpose, SmtpSecurity, SystemEmailSettings
from app.schemas.email_settings import EmailSettingsRead, EmailSettingsUpdate
def _normalize_email(email: str) -> str:
return email.strip().lower()
def _parse_allowed_domains(value: str) -> list[str]:
domains: list[str] = []
for item in (value or "").replace("", ",").split(","):
domain = item.strip().lower().lstrip("@")
if domain and domain not in domains:
domains.append(domain)
return domains or ["huapont.cn", "qq.com"]
def _normalize_domain(value: str) -> str:
return value.strip().lower().lstrip("@")
def _domain_from_email(email: str) -> str:
parts = _normalize_email(email).rsplit("@", 1)
return parts[1] if len(parts) == 2 else ""
def _as_aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _get_fernet() -> Fernet:
source = settings.SETTINGS_ENCRYPTION_KEY or settings.JWT_SECRET_KEY
try:
return Fernet(source.encode("utf-8"))
except Exception:
key = base64.urlsafe_b64encode(hashlib.sha256(source.encode("utf-8")).digest())
return Fernet(key)
def encrypt_secret(value: str) -> str:
return _get_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return _get_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="邮件授权码解密失败") from exc
async def list_email_settings(db: AsyncSession) -> list[SystemEmailSettings]:
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.register_domain.asc()))
return list(result.scalars().all())
async def get_email_settings(db: AsyncSession, register_domain: str | None = None) -> SystemEmailSettings | None:
if register_domain:
result = await db.execute(
select(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
)
return result.scalar_one_or_none()
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.created_at.asc()))
return result.scalars().first()
async def get_email_settings_for_email(db: AsyncSession, email: str) -> SystemEmailSettings | None:
domain = _domain_from_email(email)
return await get_email_settings(db, domain) if domain else None
def to_email_settings_read(settings_row: SystemEmailSettings | None) -> EmailSettingsRead:
if not settings_row:
return EmailSettingsRead()
return EmailSettingsRead(
register_domain=settings_row.register_domain,
smtp_host=settings_row.smtp_host,
smtp_port=settings_row.smtp_port,
smtp_security=settings_row.smtp_security.value,
smtp_username=settings_row.smtp_username,
smtp_password_configured=bool(settings_row.smtp_password_encrypted),
sender_email=settings_row.sender_email,
sender_name=settings_row.sender_name,
allowed_register_domain=settings_row.allowed_register_domain,
verification_code_ttl_minutes=settings_row.verification_code_ttl_minutes,
send_cooldown_seconds=settings_row.send_cooldown_seconds,
max_verify_attempts=settings_row.max_verify_attempts,
updated_at=settings_row.updated_at,
)
async def upsert_email_settings(
db: AsyncSession,
register_domain: str,
payload: EmailSettingsUpdate,
*,
updated_by: uuid.UUID,
) -> SystemEmailSettings:
register_domain = _normalize_domain(register_domain)
settings_row = await get_email_settings(db, register_domain)
if not settings_row:
settings_row = SystemEmailSettings(
register_domain=register_domain,
smtp_host=payload.smtp_host,
smtp_port=payload.smtp_port,
smtp_security=SmtpSecurity(payload.smtp_security),
smtp_username=payload.smtp_username,
smtp_password_encrypted=encrypt_secret(payload.smtp_password) if payload.smtp_password else None,
sender_email=str(payload.sender_email),
sender_name=payload.sender_name,
allowed_register_domain=register_domain,
verification_code_ttl_minutes=payload.verification_code_ttl_minutes,
send_cooldown_seconds=payload.send_cooldown_seconds,
max_verify_attempts=payload.max_verify_attempts,
updated_by=updated_by,
)
db.add(settings_row)
else:
settings_row.smtp_host = payload.smtp_host
settings_row.smtp_port = payload.smtp_port
settings_row.smtp_security = SmtpSecurity(payload.smtp_security)
settings_row.smtp_username = payload.smtp_username
if payload.smtp_password:
settings_row.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
settings_row.sender_email = str(payload.sender_email)
settings_row.sender_name = payload.sender_name
settings_row.allowed_register_domain = register_domain
settings_row.verification_code_ttl_minutes = payload.verification_code_ttl_minutes
settings_row.send_cooldown_seconds = payload.send_cooldown_seconds
settings_row.max_verify_attempts = payload.max_verify_attempts
settings_row.updated_by = updated_by
await db.commit()
await db.refresh(settings_row)
return settings_row
async def create_email_domain(db: AsyncSession, register_domain: str, *, updated_by: uuid.UUID) -> SystemEmailSettings:
register_domain = _normalize_domain(register_domain)
existing = await get_email_settings(db, register_domain)
if existing:
return existing
settings_row = SystemEmailSettings(
register_domain=register_domain,
smtp_host="",
smtp_port=465,
smtp_security=SmtpSecurity.SSL,
smtp_username="",
smtp_password_encrypted=None,
sender_email="",
sender_name="CTMS 系统",
allowed_register_domain=register_domain,
verification_code_ttl_minutes=10,
send_cooldown_seconds=60,
max_verify_attempts=5,
updated_by=updated_by,
)
db.add(settings_row)
await db.commit()
await db.refresh(settings_row)
return settings_row
async def delete_email_domain(db: AsyncSession, register_domain: str) -> None:
await db.execute(
delete(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
)
await db.commit()
def ensure_email_settings_ready(settings_row: SystemEmailSettings | None) -> SystemEmailSettings:
if not settings_row:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务尚未配置")
if not settings_row.smtp_host or not settings_row.smtp_username or not settings_row.sender_email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务配置不完整")
if not settings_row.smtp_password_encrypted:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
return settings_row
def ensure_allowed_register_email(email: str, settings_row: SystemEmailSettings) -> None:
domain = settings_row.register_domain
if not _normalize_email(email).endswith(f"@{domain}"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"仅允许 @{domain} 邮箱注册")
def _send_email_sync(
settings_row: SystemEmailSettings,
*,
to_email: str,
subject: str,
body: str,
) -> None:
password = decrypt_secret(settings_row.smtp_password_encrypted)
if not password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((settings_row.sender_name or "", settings_row.sender_email))
message["To"] = to_email
message.set_content(body)
if settings_row.smtp_security == SmtpSecurity.SSL:
with smtplib.SMTP_SSL(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
smtp.login(settings_row.smtp_username, password)
smtp.send_message(message)
else:
with smtplib.SMTP(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
if settings_row.smtp_security == SmtpSecurity.STARTTLS:
smtp.starttls()
smtp.login(settings_row.smtp_username, password)
smtp.send_message(message)
async def send_email(
settings_row: SystemEmailSettings,
*,
to_email: str,
subject: str,
body: str,
) -> None:
try:
await asyncio.to_thread(_send_email_sync, settings_row, to_email=to_email, subject=subject, body=body)
except HTTPException:
raise
except ssl.SSLError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(
"邮件发送失败:SMTP 安全协议或端口不匹配,"
"请确认当前后缀的 SMTP 地址、端口和安全协议。"
"SSL 通常使用 465STARTTLS 通常使用 587。"
),
) from exc
except smtplib.SMTPAuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="邮件发送失败:SMTP 账号或授权码不正确",
) from exc
except (smtplib.SMTPRecipientsRefused, smtplib.SMTPDataError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邮箱不存在或无法接收邮件,请检查邮箱地址",
) from exc
except (smtplib.SMTPConnectError, TimeoutError, OSError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="邮件发送失败:无法连接 SMTP 服务器,请检查 SMTP 地址、端口和网络连通性",
) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"邮件发送失败:{exc}") from exc
async def send_test_email(db: AsyncSession, register_domain: str, recipient_email: str) -> None:
settings_row = ensure_email_settings_ready(await get_email_settings(db, register_domain))
await send_email(
settings_row,
to_email=recipient_email,
subject="CTMS 邮件服务测试",
body="这是一封来自 CTMS 系统的邮件服务测试邮件。收到此邮件说明 SMTP 配置可用。",
)
async def send_register_code(db: AsyncSession, email: str) -> None:
email = _normalize_email(email)
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
ensure_allowed_register_email(email, settings_row)
if await user_crud.get_by_email(db, email):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await _send_verification_code(
db,
settings_row=settings_row,
email=email,
purpose=EmailVerificationPurpose.REGISTER,
subject="CTMS 注册邮箱验证码",
body_factory=lambda code: (
f"您的 CTMS 注册验证码是:{code}\n\n"
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
"如非本人操作,请忽略此邮件。"
),
)
async def send_password_reset_code(db: AsyncSession, email: str) -> None:
email = _normalize_email(email)
db_user = await user_crud.get_by_email(db, email)
if not db_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该邮箱未注册")
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
await _send_verification_code(
db,
settings_row=settings_row,
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
subject="CTMS 密码重置验证码",
body_factory=lambda code: (
f"您的 CTMS 密码重置验证码是:{code}\n\n"
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
"如非本人操作,请尽快联系系统管理员。"
),
)
async def send_password_reset_link(db: AsyncSession, email: str, *, frontend_origin: str) -> None:
email = _normalize_email(email)
db_user = await user_crud.get_by_email(db, email)
if not db_user:
return
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
now = datetime.now(timezone.utc)
latest_result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="重置邮件发送过于频繁,请稍后再试")
token = secrets.token_urlsafe(32)
token_hash = _hash_reset_token(token)
record = EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
code_hash=token_hash,
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
)
db.add(record)
await db.commit()
origin = frontend_origin.rstrip("/") if frontend_origin else ""
reset_link = f"{origin}/forgot-password?token={token}" if origin else f"/forgot-password?token={token}"
await send_email(
settings_row,
to_email=email,
subject="CTMS 密码重置链接",
body=(
"请点击以下链接重置您的 CTMS 登录密码:\n\n"
f"{reset_link}\n\n"
f"链接 {settings_row.verification_code_ttl_minutes} 分钟内有效,且只能使用一次。"
"如非本人操作,请忽略此邮件并尽快联系系统管理员。"
),
)
def _hash_reset_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def _send_verification_code(
db: AsyncSession,
*,
settings_row: SystemEmailSettings,
email: str,
purpose: EmailVerificationPurpose,
subject: str,
body_factory,
) -> None:
now = datetime.now(timezone.utc)
latest_result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="验证码发送过于频繁,请稍后再试")
code = f"{random.SystemRandom().randint(0, 999999):06d}"
record = EmailVerificationCode(
email=email,
purpose=purpose,
code_hash=hash_password(code),
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
)
db.add(record)
await db.commit()
await send_email(
settings_row,
to_email=email,
subject=subject,
body=body_factory(code),
)
async def verify_register_code(db: AsyncSession, email: str, code: str) -> bool:
email = _normalize_email(email)
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
ensure_allowed_register_email(email, settings_row)
await _verify_email_code(
db,
settings_row=settings_row,
email=email,
code=code,
purpose=EmailVerificationPurpose.REGISTER,
mark_verified=True,
)
return True
async def verify_password_reset_code(db: AsyncSession, email: str, code: str) -> str:
email = _normalize_email(email)
if not await user_crud.get_by_email(db, email):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
await _verify_email_code(
db,
settings_row=settings_row,
email=email,
code=code,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
mark_verified=True,
commit=False,
)
now = datetime.now(timezone.utc)
token = secrets.token_urlsafe(32)
db.add(
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
code_hash=_hash_reset_token(token),
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
)
)
await db.commit()
return token
async def reset_password_with_code(db: AsyncSession, email: str, code: str, password: str) -> None:
email = _normalize_email(email)
db_user = await user_crud.get_by_email(db, email)
if not db_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
await _verify_email_code(
db,
settings_row=settings_row,
email=email,
code=code,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
mark_verified=True,
commit=False,
)
db_user.password_hash = hash_password(password)
await db.commit()
async def reset_password_with_token(db: AsyncSession, token: str, password: str) -> None:
now = datetime.now(timezone.utc)
token_hash = _hash_reset_token(token)
result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.code_hash == token_hash,
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
EmailVerificationCode.verified_at.is_(None),
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
.with_for_update()
)
record = result.scalar_one_or_none()
if not record or record.verified_at is not None or _as_aware(record.expires_at) < now:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
db_user = await user_crud.get_by_email(db, record.email)
if not db_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
record.verified_at = now
db_user.password_hash = hash_password(password)
await db.commit()
async def _verify_email_code(
db: AsyncSession,
*,
settings_row: SystemEmailSettings,
email: str,
code: str,
purpose: EmailVerificationPurpose,
mark_verified: bool,
commit: bool = True,
) -> EmailVerificationCode:
now = datetime.now(timezone.utc)
result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(desc(EmailVerificationCode.created_at))
.limit(1)
.with_for_update()
)
record = result.scalar_one_or_none()
if (
not record
or record.verified_at is not None
or _as_aware(record.expires_at) < now
or record.attempt_count >= settings_row.max_verify_attempts
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
record.attempt_count += 1
if not verify_password(code, record.code_hash):
await db.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
if mark_verified:
record.verified_at = now
if commit:
await db.commit()
return record
async def ensure_register_email_verified(db: AsyncSession, email: str) -> None:
email = _normalize_email(email)
now = datetime.now(timezone.utc)
result = await db.execute(
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == EmailVerificationPurpose.REGISTER,
EmailVerificationCode.verified_at.is_not(None),
EmailVerificationCode.expires_at >= now,
)
.order_by(desc(EmailVerificationCode.verified_at))
.limit(1)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先完成邮箱验证码校验")
+312 -33
View File
@@ -3,10 +3,12 @@ import pytest_asyncio
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import HTTPException
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import UUID as SA_UUID, select from sqlalchemy import UUID as SA_UUID, select
from sqlalchemy.dialects.postgresql import UUID as PG_UUID from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from datetime import datetime, timedelta, timezone
import base64 import base64
import json import json
import os import os
@@ -14,9 +16,17 @@ import os
from app.main import create_app from app.main import create_app
from app.core.config import settings from app.core.config import settings
from app.core.deps import get_db_session from app.core.deps import get_db_session
from app.core.security import hash_password from app.core.security import hash_password, verify_password
from app.crud import user as user_crud from app.crud import user as user_crud
from app.db.base_class import Base from app.db.base_class import Base
from app.models.audit_log import AuditLog
from app.models.email_settings import (
EmailVerificationCode,
EmailVerificationPurpose,
SmtpSecurity,
SystemEmailSettings,
)
from app.services import email_service
from tests.conftest import GUID from tests.conftest import GUID
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
from app.models.user import User, UserStatus from app.models.user import User, UserStatus
@@ -25,6 +35,41 @@ from app.schemas.user import UserRegisterRequest
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
async def mark_register_email_verified(SessionLocal, email: str) -> None:
now = datetime.now(timezone.utc)
async with SessionLocal() as session:
session.add(
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.REGISTER,
code_hash=hash_password("123456"),
expires_at=now + timedelta(minutes=10),
verified_at=now,
)
)
await session.commit()
async def add_email_settings(SessionLocal, domain: str) -> None:
async with SessionLocal() as session:
session.add(
SystemEmailSettings(
register_domain=domain,
smtp_host="smtp.test",
smtp_port=465,
smtp_security=SmtpSecurity.SSL,
smtp_username="mailer",
smtp_password_encrypted="encrypted",
sender_email=f"mailer@{domain}",
allowed_register_domain=domain,
verification_code_ttl_minutes=10,
send_cooldown_seconds=60,
max_verify_attempts=5,
)
)
await session.commit()
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict: async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
key_resp = await client.get("/api/v1/auth/login-key") key_resp = await client.get("/api/v1/auth/login-key")
assert key_resp.status_code == 200 assert key_resp.status_code == 200
@@ -114,7 +159,7 @@ async def client_and_db():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_creates_pending_user(client_and_db): async def test_register_creates_active_user(client_and_db):
client, SessionLocal = client_and_db client, SessionLocal = client_and_db
payload = { payload = {
"email": "newuser@test.com", "email": "newuser@test.com",
@@ -122,12 +167,13 @@ async def test_register_creates_pending_user(client_and_db):
"full_name": "New User", "full_name": "New User",
"clinical_department": "Clinical", "clinical_department": "Clinical",
} }
await mark_register_email_verified(SessionLocal, payload["email"])
resp = await client.post("/api/v1/auth/register", json=payload) resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code == 201 assert resp.status_code == 201
async with SessionLocal() as session: async with SessionLocal() as session:
user = await user_crud.get_by_email(session, payload["email"]) user = await user_crud.get_by_email(session, payload["email"])
assert user is not None assert user is not None
assert user.status == UserStatus.PENDING assert user.status == UserStatus.ACTIVE
assert user.is_admin is False assert user.is_admin is False
member_rows = ( member_rows = (
await session.execute(select(StudyMember).where(StudyMember.user_id == user.id)) await session.execute(select(StudyMember).where(StudyMember.user_id == user.id))
@@ -135,6 +181,201 @@ async def test_register_creates_pending_user(client_and_db):
assert member_rows == [] assert member_rows == []
@pytest.mark.asyncio
async def test_register_rejects_duplicate_email(client_and_db):
client, SessionLocal = client_and_db
payload = {
"email": "duplicate@test.com",
"password": "Password123",
"full_name": "Duplicate User",
"clinical_department": "Clinical",
}
await mark_register_email_verified(SessionLocal, payload["email"])
first_resp = await client.post("/api/v1/auth/register", json=payload)
assert first_resp.status_code == 201
second_resp = await client.post("/api/v1/auth/register", json=payload)
assert second_resp.status_code == 409
assert second_resp.json()["detail"] == "邮箱已注册"
@pytest.mark.asyncio
async def test_register_requires_verified_email_code(client_and_db):
client, _ = client_and_db
payload = {
"email": "unverified@test.com",
"password": "Password123",
"full_name": "Unverified User",
"clinical_department": "Clinical",
}
resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code == 400
assert resp.json()["detail"] == "请先完成邮箱验证码校验"
@pytest.mark.asyncio
async def test_register_email_availability(client_and_db):
client, SessionLocal = client_and_db
available_resp = await client.get(
"/api/v1/auth/register/email-availability",
params={"email": "available@test.com"},
)
assert available_resp.status_code == 200
assert available_resp.json() == {"available": True}
payload = {
"email": "used@test.com",
"password": "Password123",
"full_name": "Used User",
"clinical_department": "Clinical",
}
await mark_register_email_verified(SessionLocal, payload["email"])
await client.post("/api/v1/auth/register", json=payload)
used_resp = await client.get(
"/api/v1/auth/register/email-availability",
params={"email": payload["email"]},
)
assert used_resp.status_code == 200
assert used_resp.json() == {"available": False}
@pytest.mark.asyncio
async def test_password_reset_code_is_one_time_and_ignores_link_records(client_and_db):
_, SessionLocal = client_and_db
email = "reset-once@test.com"
now = datetime.now(timezone.utc)
await add_email_settings(SessionLocal, "test.com")
async with SessionLocal() as session:
user = User(
email=email,
password_hash=hash_password("OldPassword123"),
full_name="Reset Once",
clinical_department="Clinical",
status=UserStatus.ACTIVE,
)
session.add(user)
session.add_all(
[
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
code_hash=hash_password("123456"),
expires_at=now + timedelta(minutes=10),
created_at=now,
),
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
code_hash="a" * 64,
expires_at=now + timedelta(minutes=10),
created_at=now + timedelta(seconds=1),
),
]
)
await session.commit()
await email_service.reset_password_with_code(session, email, "123456", "NewPassword123")
await session.refresh(user)
assert verify_password("NewPassword123", user.password_hash)
with pytest.raises(HTTPException) as exc_info:
await email_service.reset_password_with_code(session, email, "123456", "OtherPassword123")
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_password_reset_code_verification_issues_one_time_reset_token(client_and_db):
client, SessionLocal = client_and_db
email = "verified-reset@test.com"
now = datetime.now(timezone.utc)
await add_email_settings(SessionLocal, "test.com")
async with SessionLocal() as session:
session.add(
User(
email=email,
password_hash=hash_password("OldPassword123"),
full_name="Verified Reset",
clinical_department="Clinical",
status=UserStatus.ACTIVE,
)
)
session.add(
EmailVerificationCode(
email=email,
purpose=EmailVerificationPurpose.PASSWORD_RESET,
code_hash=hash_password("654321"),
expires_at=now + timedelta(minutes=10),
)
)
await session.commit()
verify_response = await client.post(
"/api/v1/auth/password-reset/email-code/verify",
json={"email": email, "code": "654321"},
)
assert verify_response.status_code == 200
assert verify_response.json()["verified"] is True
reset_token = verify_response.json()["reset_token"]
reused_code_response = await client.post(
"/api/v1/auth/password-reset/email-code/verify",
json={"email": email, "code": "654321"},
)
assert reused_code_response.status_code == 400
reset_response = await client.post(
"/api/v1/auth/password-reset-link",
json={"token": reset_token, "password": "NewPassword123"},
)
assert reset_response.status_code == 200
reused_token_response = await client.post(
"/api/v1/auth/password-reset-link",
json={"token": reset_token, "password": "OtherPassword123"},
)
assert reused_token_response.status_code == 400
async with SessionLocal() as session:
user = await user_crud.get_by_email(session, email)
assert user is not None
assert verify_password("NewPassword123", user.password_hash)
@pytest.mark.asyncio
async def test_password_reset_code_send_reports_missing_account(client_and_db):
client, _ = client_and_db
response = await client.post(
"/api/v1/auth/password-reset/email-code/send",
json={"email": "missing@test.com"},
)
assert response.status_code == 404
assert response.json()["detail"] == "该邮箱未注册"
@pytest.mark.asyncio
async def test_password_reset_link_uses_configured_frontend_url(client_and_db, monkeypatch):
client, _ = client_and_db
captured: dict[str, str] = {}
async def fake_send_password_reset_link(db, email: str, *, frontend_origin: str) -> None:
captured["email"] = email
captured["frontend_origin"] = frontend_origin
monkeypatch.setattr(email_service, "send_password_reset_link", fake_send_password_reset_link)
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "https://ctms.example.com")
response = await client.post(
"/api/v1/auth/password-reset-link/send",
json={"email": "victim@test.com"},
headers={"Origin": "https://attacker.example"},
)
assert response.status_code == 200
assert captured == {
"email": "victim@test.com",
"frontend_origin": "https://ctms.example.com",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_root_returns_service_metadata(client_and_db): async def test_root_returns_service_metadata(client_and_db):
client, _ = client_and_db client, _ = client_and_db
@@ -150,44 +391,41 @@ async def test_root_returns_service_metadata(client_and_db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_blocked_before_approval(client_and_db): async def test_registered_user_can_login_after_email_verification(client_and_db):
client, SessionLocal = client_and_db client, SessionLocal = client_and_db
payload = { payload = {
"email": "pending@test.com", "email": "active-register@test.com",
"password": "Password123", "password": "Password123",
"full_name": "Pending User", "full_name": "Active Registered User",
"clinical_department": "Safety", "clinical_department": "Safety",
} }
await mark_register_email_verified(SessionLocal, payload["email"])
await client.post("/api/v1/auth/register", json=payload) await client.post("/api/v1/auth/register", json=payload)
resp = await encrypted_login(client, payload["email"], payload["password"]) resp = await encrypted_login(client, payload["email"], payload["password"])
assert resp.status_code == 401 assert resp.status_code == 200
assert "账号未审核" in resp.json().get("detail", "") assert resp.json()["access_token"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_admin_can_approve_user(client_and_db): async def test_admin_created_user_is_active_by_default(client_and_db):
client, SessionLocal = client_and_db client, SessionLocal = client_and_db
payload = {
"email": "approve@test.com",
"password": "Password123",
"full_name": "Approve Target",
"clinical_department": "Supply",
}
await client.post("/api/v1/auth/register", json=payload)
async with SessionLocal() as session:
user = await user_crud.get_by_email(session, payload["email"])
user_id = user.id
admin_login = await encrypted_login(client, "admin@test.com", "admin123") admin_login = await encrypted_login(client, "admin@test.com", "admin123")
token = admin_login.json()["access_token"] token = admin_login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
payload = {
"email": "admin-created@test.com",
"password": "Password123",
"full_name": "Admin Created",
"clinical_department": "Supply",
}
resp = await client.post(f"/api/v1/admin/users/{user_id}/approve", json={"action": "approve"}, headers=headers) resp = await client.post("/api/v1/users/", json=payload, headers=headers)
assert resp.status_code == 200
assert resp.status_code == 201
assert resp.json()["status"] == "ACTIVE"
async with SessionLocal() as session: async with SessionLocal() as session:
refreshed = await user_crud.get_by_email(session, payload["email"]) user = await user_crud.get_by_email(session, payload["email"])
assert refreshed.status == UserStatus.ACTIVE assert user.status == UserStatus.ACTIVE
assert refreshed.approved_by is not None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -248,6 +486,43 @@ async def test_admin_users_list_filters_by_keyword_and_status(client_and_db):
assert combined_data["items"][0]["email"] == "pending-filter@test.com" assert combined_data["items"][0]["email"] == "pending-filter@test.com"
@pytest.mark.asyncio
async def test_admin_delete_user_with_audit_history_returns_400(client_and_db):
client, SessionLocal = client_and_db
async with SessionLocal() as session:
user = User(
email="audited-delete@test.com",
password_hash=hash_password("Password123"),
full_name="Audited Delete",
clinical_department="Medical",
status=UserStatus.ACTIVE,
)
session.add(user)
await session.flush()
session.add(
AuditLog(
entity_type="user",
entity_id=user.id,
action="LOGIN",
operator_id=user.id,
operator_role="USER",
)
)
await session.commit()
user_id = user.id
admin_login = await encrypted_login(client, "admin@test.com", "admin123")
token = admin_login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = await client.delete(f"/api/v1/users/{user_id}", headers=headers)
assert resp.status_code == 400
assert resp.json()["detail"] == "该账号已有审计或权限访问记录,请停用账号以保留历史追溯"
async with SessionLocal() as session:
assert await user_crud.get_by_id(session, user_id) is not None
def test_register_request_does_not_expose_role_input(): def test_register_request_does_not_expose_role_input():
assert "role" not in UserRegisterRequest.model_fields assert "role" not in UserRegisterRequest.model_fields
@@ -313,17 +588,21 @@ async def test_dev_login_rejects_inactive_users(client_and_db):
client, SessionLocal = client_and_db client, SessionLocal = client_and_db
original_env = settings.ENV original_env = settings.ENV
settings.ENV = "development" settings.ENV = "development"
payload = {
"email": "pending-dev-login@test.com",
"password": "Password123",
"full_name": "Pending Dev Login",
"clinical_department": "Clinical",
}
try: try:
await client.post("/api/v1/auth/register", json=payload) async with SessionLocal() as session:
session.add(
User(
email="pending-dev-login@test.com",
password_hash=hash_password("Password123"),
full_name="Pending Dev Login",
clinical_department="Clinical",
status=UserStatus.PENDING,
)
)
await session.commit()
resp = await client.post( resp = await client.post(
"/api/v1/auth/dev-login", "/api/v1/auth/dev-login",
json={"email": payload["email"], "password": payload["password"]}, json={"email": "pending-dev-login@test.com", "password": "Password123"},
) )
finally: finally:
settings.ENV = original_env settings.ENV = original_env
+2
View File
@@ -30,6 +30,7 @@ services:
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
ENV: ${ENV:-development} ENV: ${ENV:-development}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret} JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret}
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-} LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default} LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
depends_on: depends_on:
@@ -48,6 +49,7 @@ services:
DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db
ENV: ${ENV:-development} ENV: ${ENV:-development}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret} JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret}
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-} LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default} LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
depends_on: depends_on:
+14 -8
View File
@@ -1,12 +1,18 @@
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { apiGet, apiPost } from "./axios"; import { apiDelete, apiGet, apiPost, apiPut } from "./axios";
import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api"; import type { EmailSettings, EmailSettingsListResponse, EmailSettingsUpdate } from "../types/api";
export const listPendingUsers = (status: UserStatus = "PENDING"): Promise<AxiosResponse<AdminUserListResponse>> => export const fetchEmailSettings = (): Promise<AxiosResponse<EmailSettingsListResponse>> =>
apiGet("/api/v1/admin/users/", { params: { status } }); apiGet<EmailSettingsListResponse>("/api/v1/admin/email-settings/");
export const approveUser = (userId: string): Promise<AxiosResponse<UserInfo>> => export const createEmailSettingsDomain = (register_domain: string): Promise<AxiosResponse<EmailSettings>> =>
apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" }); apiPost<EmailSettings>("/api/v1/admin/email-settings/", { register_domain });
export const rejectUser = (userId: string): Promise<AxiosResponse<UserInfo>> => export const updateEmailSettings = (registerDomain: string, payload: EmailSettingsUpdate): Promise<AxiosResponse<EmailSettings>> =>
apiPost(`/api/v1/admin/users/${userId}/reject`, { action: "reject" }); apiPut<EmailSettings>(`/api/v1/admin/email-settings/${registerDomain}`, payload);
export const deleteEmailSettingsDomain = (registerDomain: string): Promise<AxiosResponse<{ message: string }>> =>
apiDelete(`/api/v1/admin/email-settings/${registerDomain}`);
export const sendEmailSettingsTest = (registerDomain: string, recipient_email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost(`/api/v1/admin/email-settings/${registerDomain}/test`, { recipient_email });
+38
View File
@@ -7,6 +7,12 @@ import type {
LoginResponse, LoginResponse,
LoginKeyResponse, LoginKeyResponse,
RegisterRequest, RegisterRequest,
EmailAvailabilityResponse,
EmailDomainsResponse,
EmailCodeVerifyResponse,
PasswordResetCodeVerifyResponse,
PasswordResetRequest,
PasswordResetTokenRequest,
} from "../types/api"; } from "../types/api";
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> => export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
@@ -20,9 +26,41 @@ export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me"); export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export const fetchEmailDomains = (): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains");
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> => export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register", payload); apiPost("/api/v1/auth/register", payload);
export const checkEmailAvailability = (email: string): Promise<AxiosResponse<EmailAvailabilityResponse>> =>
apiGet<EmailAvailabilityResponse>("/api/v1/auth/register/email-availability", { params: { email } });
export const sendRegisterEmailCode = (email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register/email-code/send", { email });
export const verifyRegisterEmailCode = (email: string, code: string): Promise<AxiosResponse<EmailCodeVerifyResponse>> =>
apiPost<EmailCodeVerifyResponse>("/api/v1/auth/register/email-code/verify", { email, code });
export const sendPasswordResetEmailCode = (email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset/email-code/send", { email });
export const verifyPasswordResetEmailCode = (
email: string,
code: string
): Promise<AxiosResponse<PasswordResetCodeVerifyResponse>> =>
apiPost<PasswordResetCodeVerifyResponse>("/api/v1/auth/password-reset/email-code/verify", { email, code });
export const sendPasswordResetLink = (email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset-link/send", { email });
export const resetPassword = (payload: PasswordResetRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset", payload);
export const resetPasswordWithToken = (
payload: PasswordResetTokenRequest
): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset-link", payload);
export const updateProfile = (payload: { export const updateProfile = (payload: {
full_name?: string; full_name?: string;
clinical_department?: string; clinical_department?: string;
+1
View File
@@ -56,6 +56,7 @@ instance.interceptors.response.use(
const isAuthEndpoint = const isAuthEndpoint =
reqUrl.includes("/api/v1/auth/login") || reqUrl.includes("/api/v1/auth/login") ||
reqUrl.includes("/api/v1/auth/register") || reqUrl.includes("/api/v1/auth/register") ||
reqUrl.includes("/api/v1/auth/password-reset") ||
reqUrl.includes("/api/v1/auth/extend"); reqUrl.includes("/api/v1/auth/extend");
if (isAuthEndpoint) { if (isAuthEndpoint) {
// 认证相关的错误由具体页面自行处理,避免重复提示 // 认证相关的错误由具体页面自行处理,避免重复提示
+1 -1
View File
@@ -20,4 +20,4 @@ export const updateUser = (
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload); apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
export const deleteUser = (userId: string) => export const deleteUser = (userId: string) =>
apiDelete<void>(`/api/v1/users/${userId}`); apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true });
+9 -1
View File
@@ -32,6 +32,13 @@
<el-icon><DataAnalysis /></el-icon> <el-icon><DataAnalysis /></el-icon>
<span>{{ TEXT.menu.systemMonitoring }}</span> <span>{{ TEXT.menu.systemMonitoring }}</span>
</el-menu-item> </el-menu-item>
<el-sub-menu v-if="isAdmin" index="system-settings">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="/admin/email-settings">邮件服务</el-menu-item>
</el-sub-menu>
<el-sub-menu v-if="canAccessAdminPermissions" index="permissions"> <el-sub-menu v-if="canAccessAdminPermissions" index="permissions">
<template #title> <template #title>
<el-icon><Key /></el-icon> <el-icon><Key /></el-icon>
@@ -337,7 +344,7 @@ import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { import {
User, Suitcase, House, Calendar, Flag, User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus"; import { ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions"; import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
@@ -596,6 +603,7 @@ const breadcrumbs = computed(() => {
topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" }); topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" });
if (isAdmin.value) topItems.push({ label: TEXT.menu.auditLogs, path: "/admin/audit-logs" }); if (isAdmin.value) topItems.push({ label: TEXT.menu.auditLogs, path: "/admin/audit-logs" });
if (isAdmin.value) topItems.push({ label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring" }); if (isAdmin.value) topItems.push({ label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring" });
if (isAdmin.value) topItems.push({ label: "邮件服务", path: "/admin/email-settings" });
if (canAccessAdminPermissions.value) topItems.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system" }); if (canAccessAdminPermissions.value) topItems.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system" });
// //
+1 -1
View File
@@ -52,7 +52,7 @@ export const privacyPolicySections: AuthProtocolSection[] = [
{ {
title: "2. 数据使用与最小必要", title: "2. 数据使用与最小必要",
paragraphs: [ paragraphs: [
"平台仅在账号注册审核、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。", "平台仅在账号注册、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
"用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。", "用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。",
], ],
}, },
+19 -23
View File
@@ -329,9 +329,9 @@ export const TEXT = {
footer: "© 2025 CTMS Enterprise. 专业临床研究团队支持。", footer: "© 2025 CTMS Enterprise. 专业临床研究团队支持。",
authFailed: "认证失败,请检查您的凭据", authFailed: "认证失败,请检查您的凭据",
registerTitle: "创建账号", registerTitle: "创建账号",
registerDesc: "提交后需管理员审核通过方可登录", registerDesc: "完成邮箱验证后即可创建可用账号",
registerButton: "提交注册", registerButton: "完成注册",
registerSuccess: "注册成功,等待管理员审核", registerSuccess: "注册成功,请登录",
backToLogin: "已有账号?返回登录", backToLogin: "已有账号?返回登录",
passwordRuleMin: "密码至少 8 位", passwordRuleMin: "密码至少 8 位",
passwordRuleMix: "需包含字母和数字", passwordRuleMix: "需包含字母和数字",
@@ -775,18 +775,6 @@ export const TEXT = {
siteRequired: "中心级目录需选择分中心", siteRequired: "中心级目录需选择分中心",
}, },
}, },
adminUserApproval: {
title: "注册审核",
subtitle: "仅显示待审核账号,审核通过后即可登录",
createdAt: "注册时间",
approve: "审核通过",
reject: "拒绝",
loadFailed: "加载待审核用户失败",
approveSuccess: "已通过审核",
approveFailed: "审核失败",
rejectSuccess: "已拒绝",
rejectFailed: "拒绝失败",
},
adminUsers: { adminUsers: {
title: "账号管理(系统登录账号)", title: "账号管理(系统登录账号)",
subtitle: "账号用于登录系统,本身不具备项目或角色权限", subtitle: "账号用于登录系统,本身不具备项目或角色权限",
@@ -795,7 +783,7 @@ export const TEXT = {
createdAt: "创建时间", createdAt: "创建时间",
newTitle: "新建用户", newTitle: "新建用户",
editTitle: "编辑用户", editTitle: "编辑用户",
resetPassword: "重置密码", resetPassword: "应急重置",
accountStatus: "账号状态", accountStatus: "账号状态",
passwordOptional: "新密码(可选)", passwordOptional: "新密码(可选)",
passwordInitial: "初始密码", passwordInitial: "初始密码",
@@ -818,12 +806,18 @@ export const TEXT = {
disableTitle: "禁用账号", disableTitle: "禁用账号",
enableSuccess: "已启用", enableSuccess: "已启用",
disableSuccess: "已禁用", disableSuccess: "已禁用",
resetTitle: "重置密码", resetTitle: "应急重置密码",
resetWarningTitle: "该操作将影响用户账号,请确认是否继续", resetWarningTitle: "优先使用邮箱验证码自助找回",
resetWarningDesc: "系统将生成临时密码,需再次输入用户名才能提交。", resetWarningDesc: "仅在用户无法接收验证码、邮箱不可用或账号交接等应急场景下,由管理员手动重置密码。",
resetTarget: "重置对象:", resetTarget: "重置对象:",
confirmUsername: "确认用户名", confirmUsername: "确认用户名",
confirmUsernameHint: "请输入用户名以确认", confirmUsernameHint: "请输入用户名以确认",
selfServiceResetTitle: "发送自助重置链接",
selfServiceResetDesc: "系统会向该用户注册邮箱发送一次性链接,由用户点击后自行设置新密码。",
sendResetCode: "发送链接",
sendResetCodeSuccess: "重置链接已发送",
sendResetCodeFailed: "重置链接发送失败",
emergencyResetDivider: "邮箱不可用时使用应急重置",
passwordMode: "密码方式", passwordMode: "密码方式",
passwordAuto: "系统生成临时密码", passwordAuto: "系统生成临时密码",
passwordManual: "手动输入新密码", passwordManual: "手动输入新密码",
@@ -831,13 +825,17 @@ export const TEXT = {
newPassword: "新密码", newPassword: "新密码",
regenerate: "重新生成", regenerate: "重新生成",
manualPasswordHint: "请输入新密码", manualPasswordHint: "请输入新密码",
passwordHint: "不会明文展示,请通过安全渠道告知用户", emergencyPassword: "应急新密码",
emergencyPasswordHint: "至少 8 位且包含字母与数字",
passwordHint: "仅用于应急场景,请通过安全渠道告知用户并要求其登录后立即修改。",
tempPasswordError: "临时密码生成异常", tempPasswordError: "临时密码生成异常",
manualPasswordRequired: "请输入新密码", manualPasswordRequired: "请输入新密码",
manualPasswordRule: "密码需至少 8 位且包含字母和数字",
confirmMismatch: "确认用户名不匹配,无法提交", confirmMismatch: "确认用户名不匹配,无法提交",
resetConfirmPrompt: "该操作将影响用户账号,请确认是否继续", resetConfirmPrompt: "该操作将影响用户账号,请确认是否继续",
emergencyResetConfirmPrompt: "将由管理员直接修改该账号密码。请确认用户无法通过邮箱验证码自助找回,且已准备通过安全渠道告知新密码。",
resetConfirmTitle: "确认重置密码", resetConfirmTitle: "确认重置密码",
resetConfirm: "确认重置", resetConfirm: "确认应急重置",
resetSuccess: "密码已重置", resetSuccess: "密码已重置",
resetFailed: "重置失败", resetFailed: "重置失败",
}, },
@@ -994,9 +992,7 @@ export const TEXT = {
CTA: "CTA", CTA: "CTA",
}, },
userStatus: { userStatus: {
PENDING: "待审核",
ACTIVE: "启用", ACTIVE: "启用",
REJECTED: "已拒绝",
DISABLED: "已禁用", DISABLED: "已禁用",
}, },
subjectStatus: { subjectStatus: {
+7 -7
View File
@@ -14,11 +14,11 @@ import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue"; import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue"; import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue"; import AdminUsers from "../views/admin/Users.vue";
import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
import AdminProjects from "../views/admin/Projects.vue"; import AdminProjects from "../views/admin/Projects.vue";
import AdminSites from "../views/admin/Sites.vue"; import AdminSites from "../views/admin/Sites.vue";
import PermissionManagement from "../views/admin/PermissionManagement.vue"; import PermissionManagement from "../views/admin/PermissionManagement.vue";
import SystemMonitoringPage from "../views/admin/SystemMonitoringPage.vue"; import SystemMonitoringPage from "../views/admin/SystemMonitoringPage.vue";
import EmailSettings from "../views/admin/EmailSettings.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue"; import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProfileSettings from "../views/ProfileSettings.vue"; import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue"; import ProjectOverview from "../views/ia/ProjectOverview.vue";
@@ -334,12 +334,6 @@ const routes: RouteRecordRaw[] = [
component: AdminUsers, component: AdminUsers,
meta: { title: TEXT.menu.accountManagement, requiresAdmin: true }, meta: { title: TEXT.menu.accountManagement, requiresAdmin: true },
}, },
{
path: "user-approval",
name: "AdminUserApproval",
component: AdminUserApproval,
meta: { title: TEXT.modules.adminUserApproval.title, requiresAdmin: true },
},
{ {
path: "projects", path: "projects",
name: "AdminProjects", name: "AdminProjects",
@@ -378,6 +372,12 @@ const routes: RouteRecordRaw[] = [
component: SystemMonitoringPage, component: SystemMonitoringPage,
meta: { title: TEXT.menu.systemMonitoring, requiresAdmin: true }, meta: { title: TEXT.menu.systemMonitoring, requiresAdmin: true },
}, },
{
path: "email-settings",
name: "AdminEmailSettings",
component: EmailSettings,
meta: { title: "邮件服务", requiresAdmin: true },
},
{ {
path: "permission-monitoring", path: "permission-monitoring",
redirect: "/admin/system-monitoring", redirect: "/admin/system-monitoring",
+65
View File
@@ -63,6 +63,71 @@ export interface RegisterRequest {
clinical_department: string; clinical_department: string;
} }
export interface EmailAvailabilityResponse {
available: boolean;
}
export interface EmailDomainsResponse {
items: string[];
}
export type SmtpSecurity = "NONE" | "SSL" | "STARTTLS";
export interface EmailSettings {
register_domain: string;
smtp_host: string;
smtp_port: number;
smtp_security: SmtpSecurity;
smtp_username: string;
smtp_password_configured: boolean;
sender_email: string;
sender_name?: string | null;
allowed_register_domain: string;
verification_code_ttl_minutes: number;
send_cooldown_seconds: number;
max_verify_attempts: number;
updated_at?: string | null;
}
export interface EmailSettingsUpdate {
smtp_host: string;
smtp_port: number;
smtp_security: SmtpSecurity;
smtp_username: string;
smtp_password?: string;
sender_email: string;
sender_name?: string | null;
allowed_register_domain: string;
verification_code_ttl_minutes: number;
send_cooldown_seconds: number;
max_verify_attempts: number;
}
export interface EmailSettingsListResponse {
items: EmailSettings[];
total: number;
}
export interface EmailCodeVerifyResponse {
verified: boolean;
}
export interface PasswordResetCodeVerifyResponse {
verified: boolean;
reset_token: string;
}
export interface PasswordResetRequest {
email: string;
code: string;
password: string;
}
export interface PasswordResetTokenRequest {
token: string;
password: string;
}
export interface AdminUserListResponse { export interface AdminUserListResponse {
items: UserInfo[]; items: UserInfo[];
total: number; total: number;
+759 -12
View File
@@ -1,23 +1,770 @@
<template> <template>
<div class="forgot-container"> <div class="fp-wrapper">
<ModulePlaceholder <div class="aurora aurora-1"></div>
:title="TEXT.modules.auth.forgotTitle" <div class="aurora aurora-2"></div>
:list-title="TEXT.modules.auth.forgotTitle" <div class="noise-overlay"></div>
:empty-title="TEXT.modules.auth.forgotTitle"
:empty-description="TEXT.modules.auth.forgotDesc" <div class="fp-center">
/> <!-- 主卡片 -->
<div class="fp-card">
<!-- 返回登录 -->
<RouterLink to="/login" class="back-link">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
返回登录
</RouterLink>
<!-- 标题区横排图标 + 文字 -->
<div class="fp-header">
<div class="fp-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<div class="fp-header-text">
<h1 class="fp-title">找回密码</h1>
<p class="fp-desc">{{ tokenMode ? "请设置新的登录密码,重置链接仅可使用一次。" : "通过注册邮箱接收验证码,自助设置新的登录密码。" }}</p>
</div>
</div>
<!-- 步骤指示器 -->
<div class="steps-indicator">
<div class="step" :class="{ active: currentStep >= 1, done: currentStep > 1 }">
<div class="step-dot">
<svg v-if="currentStep > 1" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path d="M20 6 9 17l-5-5"/>
</svg>
<span v-else>1</span>
</div>
<span class="step-label">接收邮件</span>
</div>
<div class="step-line" :class="{ active: currentStep > 1 }"></div>
<div class="step" :class="{ active: currentStep >= 2 }">
<div class="step-dot"><span>2</span></div>
<span class="step-label">设置密码</span>
</div>
</div>
<!-- 错误提示 -->
<transition name="error-slide">
<div v-if="errorMessage" class="error-banner">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/>
</svg>
<span>{{ errorMessage }}</span>
<button class="error-close" @click="errorMessage = ''" aria-label="关闭">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</div>
</transition>
<!-- 表单 -->
<form class="fp-form" @submit.prevent="handleReset">
<!-- 注册邮箱 -->
<div v-if="!tokenMode" class="form-row">
<label class="form-label" for="reset-email">注册邮箱</label>
<div class="field-row">
<div class="input-wrapper" :class="{ focused: emailFocused }">
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<input
id="reset-email"
v-model.trim="form.email"
class="form-input"
type="email"
placeholder="请输入注册邮箱"
autocomplete="email"
:disabled="codeVerifying || canSetPassword"
@focus="emailFocused = true"
@blur="emailFocused = false"
/>
</div>
<button class="code-btn" type="button" :disabled="!canSendCode" @click="handleSendCode">
<span v-if="countdown > 0" class="countdown-text">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{{ countdown }}s
</span>
<span v-else-if="codeSending">发送中</span>
<span v-else>获取验证码</span>
</button>
</div>
</div>
<!-- 验证码 -->
<div v-if="!tokenMode" class="form-row">
<label class="form-label" for="reset-code">邮箱验证码</label>
<div class="field-row">
<div class="input-wrapper" :class="{ focused: codeFocused }">
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<input
id="reset-code"
v-model.trim="form.code"
class="form-input"
inputmode="numeric"
maxlength="12"
placeholder="请输入邮箱验证码"
autocomplete="one-time-code"
:disabled="codeVerifying || canSetPassword"
@focus="codeFocused = true"
@blur="codeFocused = false"
/>
</div>
<button class="code-btn verify-btn" type="button" :disabled="!canVerifyCode" @click="handleVerifyCode">
{{ canSetPassword ? "已验证" : codeVerifying ? "验证中…" : "验证" }}
</button>
</div>
</div>
<!-- 分隔线 -->
<div class="section-divider"><span>设置新密码</span></div>
<!-- 新密码 -->
<div class="form-row">
<label class="form-label" for="reset-password">新密码</label>
<div class="input-wrapper password-wrapper" :class="{ focused: pwFocused }">
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<input
id="reset-password"
v-model="form.password"
class="form-input"
:type="showPassword ? 'text' : 'password'"
placeholder="至少 8 位,包含字母和数字"
autocomplete="new-password"
:disabled="submitting || !canSetPassword"
@focus="pwFocused = true"
@blur="pwFocused = false"
/>
<button class="toggle-btn" type="button" :disabled="!canSetPassword" @click="showPassword = !showPassword">
<svg v-if="!showPassword" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
<!-- 密码强度 + 提示 内联一行 -->
<div class="pw-meta">
<div class="hint-list">
<span :class="{ valid: passwordHints.minLength, invalid: form.password && !passwordHints.minLength }">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path v-if="passwordHints.minLength" d="M20 6 9 17l-5-5"/><path v-else d="M18 6 6 18M6 6l12 12"/>
</svg>8 位以上
</span>
<span :class="{ valid: passwordHints.hasLetter, invalid: form.password && !passwordHints.hasLetter }">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path v-if="passwordHints.hasLetter" d="M20 6 9 17l-5-5"/><path v-else d="M18 6 6 18M6 6l12 12"/>
</svg>包含字母
</span>
<span :class="{ valid: passwordHints.hasNumber, invalid: form.password && !passwordHints.hasNumber }">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path v-if="passwordHints.hasNumber" d="M20 6 9 17l-5-5"/><path v-else d="M18 6 6 18M6 6l12 12"/>
</svg>包含数字
</span>
</div>
<div v-if="form.password" class="strength-mini">
<div class="strength-bars">
<div class="strength-seg" :class="{ filled: strengthScore >= 1, weak: strengthScore <= 2, medium: strengthScore === 3, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 2, weak: strengthScore <= 2 && strengthScore >= 2, medium: strengthScore === 3, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 3, medium: strengthScore === 3, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 4, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 5, strong: strengthScore >= 5 }"></div>
</div>
<span class="strength-label" :class="strengthClass">{{ strengthText }}</span>
</div>
</div>
</div>
<!-- 确认密码 -->
<div class="form-row">
<label class="form-label" for="reset-confirm">确认新密码</label>
<div
class="input-wrapper"
:class="{
focused: confirmFocused,
'match-ok': form.confirmPassword && form.confirmPassword === form.password,
'match-fail': form.confirmPassword && form.confirmPassword !== form.password
}"
>
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<input
id="reset-confirm"
v-model="form.confirmPassword"
class="form-input"
type="password"
placeholder="请再次输入新密码"
autocomplete="new-password"
:disabled="submitting || !canSetPassword"
@focus="confirmFocused = true"
@blur="confirmFocused = false"
/>
<transition name="fade">
<div v-if="form.confirmPassword" class="confirm-icon">
<svg v-if="form.confirmPassword === form.password" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.5">
<path d="M20 6 9 17l-5-5"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</div>
</transition>
</div>
</div>
<!-- 提交按钮 -->
<button class="submit-btn" type="submit" :disabled="submitting || !canSubmit">
<svg v-if="submitting" class="spin-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span>{{ submitting ? '重置中...' : '重置密码' }}</span>
<svg v-if="!submitting" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</form>
</div>
<p class="fp-tagline">Professional · Compliant · Efficient</p>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ModulePlaceholder from "../components/ModulePlaceholder.vue"; import { computed, onBeforeUnmount, reactive, ref } from "vue";
import { TEXT } from "../locales"; import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import {
resetPasswordWithToken,
sendPasswordResetEmailCode,
verifyPasswordResetEmailCode,
} from "../api/auth";
const router = useRouter();
const route = useRoute();
const form = reactive({ email: "", code: "", password: "", confirmPassword: "" });
const emailFocused = ref(false);
const codeFocused = ref(false);
const pwFocused = ref(false);
const confirmFocused = ref(false);
const codeSending = ref(false);
const codeVerifying = ref(false);
const submitting = ref(false);
const countdown = ref(0);
const showPassword = ref(false);
const errorMessage = ref("");
const verifiedResetToken = ref("");
let timer: number | undefined;
const resetToken = computed(() => {
const token = route.query.token;
return typeof token === "string" ? token : "";
});
const tokenMode = computed(() => resetToken.value.length > 0);
const effectiveResetToken = computed(() => resetToken.value || verifiedResetToken.value);
const canSetPassword = computed(() => effectiveResetToken.value.length > 0);
const currentStep = computed(() => (canSetPassword.value ? 2 : 1));
const emailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email));
const passwordHints = computed(() => ({
minLength: form.password.length >= 8,
hasLetter: /[A-Za-z]/.test(form.password),
hasNumber: /\d/.test(form.password),
}));
const passwordValid = computed(
() => passwordHints.value.minLength && passwordHints.value.hasLetter && passwordHints.value.hasNumber
);
const strengthScore = computed(() => {
let s = 0;
if (form.password.length >= 8) s++;
if (form.password.length >= 12) s++;
if (/[A-Za-z]/.test(form.password)) s++;
if (/\d/.test(form.password)) s++;
if (/[^A-Za-z0-9]/.test(form.password)) s++;
return s;
});
const strengthClass = computed(() => {
if (strengthScore.value <= 2) return "strength-weak";
if (strengthScore.value <= 3) return "strength-medium";
return "strength-strong";
});
const strengthText = computed(() => {
if (strengthScore.value <= 2) return "弱";
if (strengthScore.value <= 3) return "中";
return "强";
});
const canSendCode = computed(
() => emailValid.value && !canSetPassword.value && !codeSending.value && !codeVerifying.value && countdown.value === 0
);
const canVerifyCode = computed(
() => emailValid.value && !canSetPassword.value && form.code.trim().length >= 4 && !codeVerifying.value && !codeSending.value
);
const canSubmit = computed(
() =>
passwordValid.value &&
form.password === form.confirmPassword &&
canSetPassword.value
);
const getErrorMessage = (error: unknown, fallback: string) => {
const response = (error as { response?: { data?: { detail?: unknown; message?: string } } }).response;
const detail = response?.data?.detail;
if (typeof response?.data?.message === "string") return response.data.message;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object" && "message" in detail) {
const msg = (detail as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return fallback;
};
const startCountdown = () => {
countdown.value = 60;
timer = window.setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0 && timer) { window.clearInterval(timer); timer = undefined; }
}, 1000);
};
const handleSendCode = async () => {
errorMessage.value = "";
if (!emailValid.value) { errorMessage.value = "请输入有效的注册邮箱"; return; }
codeSending.value = true;
try {
const { data } = await sendPasswordResetEmailCode(form.email);
form.code = "";
ElMessage.success(data.message || "验证码发送成功,请查收邮箱");
startCountdown();
} catch (error) {
errorMessage.value = getErrorMessage(error, "验证码发送失败,请稍后再试");
} finally {
codeSending.value = false;
}
};
const handleVerifyCode = async () => {
errorMessage.value = "";
if (!emailValid.value) { errorMessage.value = "请输入有效的注册邮箱"; return; }
if (form.code.trim().length < 4) { errorMessage.value = "请输入邮箱验证码"; return; }
codeVerifying.value = true;
try {
const { data } = await verifyPasswordResetEmailCode(form.email, form.code);
if (!data.verified || !data.reset_token) {
errorMessage.value = "邮箱验证码校验失败";
return;
}
verifiedResetToken.value = data.reset_token;
ElMessage.success("邮箱验证通过,请设置新密码");
} catch (error) {
errorMessage.value = getErrorMessage(error, "验证码错误或已过期");
} finally {
codeVerifying.value = false;
}
};
const validateForm = () => {
if (!effectiveResetToken.value) return "请先完成邮箱验证码校验";
if (!passwordValid.value) return "密码需至少 8 位且包含字母和数字";
if (form.password !== form.confirmPassword) return "两次输入的密码不一致";
return "";
};
const handleReset = async () => {
errorMessage.value = validateForm();
if (errorMessage.value) return;
submitting.value = true;
try {
const { data } = await resetPasswordWithToken({
token: effectiveResetToken.value,
password: form.password,
});
ElMessage.success(data.message || "密码已重置,请返回登录");
await router.push("/login");
} catch (error) {
errorMessage.value = getErrorMessage(error, "密码重置失败,请重新获取验证码或重置链接");
} finally {
submitting.value = false;
}
};
onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
</script> </script>
<style scoped> <style scoped>
.forgot-container { @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@600;800&display=swap");
/*
根容器
*/
.fp-wrapper {
position: relative;
min-height: 100vh; min-height: 100vh;
background-color: var(--ctms-bg-base); display: flex;
padding: 32px; align-items: center;
justify-content: center;
overflow: hidden;
background: #eef2ff;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
}
/* 背景极光 */
.aurora {
position: absolute;
border-radius: 50%;
filter: blur(70px);
pointer-events: none;
z-index: 0;
}
.aurora-1 {
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 70%);
top: -200px; left: -160px;
animation: auroraFloat1 16s ease-in-out infinite alternate;
}
.aurora-2 {
width: 500px; height: 500px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.16) 0%, transparent 70%);
bottom: -160px; right: -120px;
animation: auroraFloat2 20s ease-in-out infinite alternate;
}
@keyframes auroraFloat1 {
0% { transform: translate(0, 0); }
100% { transform: translate(30px, 40px); }
}
@keyframes auroraFloat2 {
0% { transform: translate(0, 0); }
100% { transform: translate(-25px, -35px); }
}
.noise-overlay {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
opacity: 0.02;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 200px;
}
/* 页面中心 */
.fp-center {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 100%;
max-width: 460px;
padding: 16px 20px;
animation: cardIn 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(18px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/*
主卡片
*/
.fp-card {
width: 100%;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.95);
border-radius: 24px;
padding: 22px 28px 20px;
box-shadow:
0 0 0 1px rgba(99, 102, 241, 0.05) inset,
0 20px 56px -10px rgba(99, 102, 241, 0.14),
0 6px 20px -4px rgba(0, 0, 0, 0.05);
}
.back-link {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 600;
text-decoration: none; color: #9ca3af;
margin-bottom: 16px;
transition: color 0.2s;
}
.back-link:hover { color: #2563eb; }
/* 标题区(横排) */
.fp-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
}
.fp-icon {
display: flex; align-items: center; justify-content: center;
width: 48px; height: 48px; flex-shrink: 0;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.09), rgba(6, 182, 212, 0.09));
border: 1px solid rgba(37, 99, 235, 0.16);
border-radius: 14px;
color: #2563eb;
box-shadow: 0 4px 14px -4px rgba(37, 99, 235, 0.18);
}
.fp-header-text { flex: 1; }
.fp-title {
font-size: 18px; font-weight: 800;
color: #0f172a; margin: 0 0 3px;
letter-spacing: -0.3px;
}
.fp-desc {
font-size: 12px; color: #94a3b8;
margin: 0; line-height: 1.5;
}
/* 步骤指示器 */
.steps-indicator {
display: flex; align-items: center; gap: 6px;
margin-bottom: 12px;
padding: 8px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
}
.step { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.step-dot {
width: 20px; height: 20px;
border-radius: 50%; background: #e2e8f0;
color: #94a3b8; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
transition: all 0.3s ease;
}
.step.active .step-dot { background: #2563eb; color: white; box-shadow: 0 3px 8px rgba(37, 99, 235, 0.28); }
.step.done .step-dot { background: #10b981; color: white; box-shadow: 0 3px 8px rgba(16, 185, 129, 0.28); }
.step-label { font-size: 11px; font-weight: 600; color: #94a3b8; transition: color 0.3s; }
.step.active .step-label { color: #2563eb; }
.step.done .step-label { color: #10b981; }
.step-line {
flex: 1; height: 2px; background: #e2e8f0;
border-radius: 2px; transition: background 0.4s;
}
.step-line.active { background: linear-gradient(to right, #10b981, #2563eb); }
/* 错误提示 */
.error-banner {
display: flex; align-items: center; gap: 7px;
padding: 8px 12px; margin-bottom: 10px;
background: #fef2f2; border: 1px solid #fecaca;
border-radius: 9px; color: #dc2626;
font-size: 12px; font-weight: 500;
}
.error-banner svg { flex-shrink: 0; }
.error-banner span { flex: 1; line-height: 1.4; }
.error-close {
display: flex; align-items: center; justify-content: center;
border: none; background: none; cursor: pointer;
color: #dc2626; opacity: 0.5; padding: 2px;
transition: opacity 0.2s;
}
.error-close:hover { opacity: 1; }
.error-slide-enter-active, .error-slide-leave-active { transition: all 0.22s ease; }
.error-slide-enter-from, .error-slide-leave-to { opacity: 0; transform: translateY(-4px); }
/* 表单 */
.fp-form {
display: flex; flex-direction: column; gap: 10px;
}
.form-row {
display: flex; flex-direction: column; gap: 5px;
}
.form-label {
font-size: 11px; font-weight: 700;
color: #475569; letter-spacing: 0.2px;
}
/* 邮箱行 */
.field-row { display: flex; align-items: center; gap: 8px; }
.field-row .input-wrapper { flex: 1; }
/* 输入框容器 */
.input-wrapper {
position: relative; display: flex; align-items: center;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
background: rgba(255, 255, 255, 0.55);
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.input-wrapper.focused {
border-color: #2563eb; background: #ffffff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
}
.input-wrapper.match-ok { border-color: #10b981; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08); }
.input-wrapper.match-fail { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.08); }
.input-icon {
display: flex; align-items: center; justify-content: center;
width: 36px; flex-shrink: 0; color: #cbd5e1;
transition: color 0.2s;
}
.input-wrapper.focused .input-icon { color: #2563eb; }
.form-input {
flex: 1; height: 40px;
border: none; background: transparent;
color: #0f172a; font-size: 13px; font-family: inherit;
outline: none; padding: 0 10px 0 0;
}
.form-input::placeholder { color: #c4cdd6; }
.form-input:disabled { opacity: 0.55; cursor: not-allowed; }
/* 密码显隐 */
.password-wrapper { padding-right: 3px; }
.toggle-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border: none;
background: rgba(148, 163, 184, 0.1);
border-radius: 7px; cursor: pointer;
color: #94a3b8; flex-shrink: 0; margin-right: 3px;
transition: background 0.2s, color 0.2s;
}
.toggle-btn:hover { background: rgba(37, 99, 235, 0.1); color: #2563eb; }
.toggle-btn:disabled { cursor: not-allowed; opacity: 0.45; }
/* 确认密码校验图标 */
.confirm-icon {
display: flex; align-items: center;
padding-right: 10px; flex-shrink: 0;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* 获取验证码 */
.code-btn {
flex: 0 0 auto; height: 40px;
border: 1.5px solid #e2e8f0; border-radius: 10px;
background: #f8fafc; color: #2563eb;
font-size: 12px; font-weight: 700;
padding: 0 13px; cursor: pointer; white-space: nowrap;
font-family: inherit;
display: flex; align-items: center; gap: 4px;
transition: all 0.2s ease;
}
.code-btn:not(:disabled):hover {
background: #eff6ff; border-color: #93c5fd;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.1);
}
.code-btn:disabled {
cursor: not-allowed; color: #94a3b8;
background: #f1f5f9; border-color: #e2e8f0;
}
.verify-btn { min-width: 72px; justify-content: center; }
.countdown-text { display: flex; align-items: center; gap: 3px; color: #94a3b8; }
/* 分割线 */
.section-divider {
display: flex; align-items: center; gap: 10px; margin: 1px 0;
}
.section-divider::before, .section-divider::after {
content: ""; flex: 1; height: 1px; background: #f1f5f9;
}
.section-divider span {
font-size: 10px; font-weight: 700; color: #d1d5db;
letter-spacing: 0.8px; text-transform: uppercase; white-space: nowrap;
}
/* 密码元信息(提示 + 强度条 同行) */
.pw-meta {
display: flex; align-items: center;
justify-content: space-between; gap: 8px;
margin-top: 1px;
}
.hint-list { display: flex; flex-wrap: wrap; gap: 4px; }
.hint-list span {
display: inline-flex; align-items: center; gap: 3px;
font-size: 10.5px; font-weight: 600;
color: #94a3b8; background: #f8fafc;
border: 1px solid #f1f5f9; border-radius: 999px;
padding: 2px 8px; transition: all 0.2s;
}
.hint-list span.valid { color: #059669; background: rgba(16,185,129,.08); border-color: rgba(16,185,129,.2); }
.hint-list span.invalid { color: #dc2626; background: rgba(239,68,68,.06); border-color: rgba(239,68,68,.18); }
/* 密码强度条 */
.strength-mini { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.strength-bars { display: flex; gap: 2px; }
.strength-seg {
width: 16px; height: 3px; border-radius: 2px;
background: #f1f5f9; transition: background 0.3s;
}
.strength-seg.filled.weak { background: #ef4444; }
.strength-seg.filled.medium { background: #f59e0b; }
.strength-seg.filled.strong { background: #10b981; }
.strength-label { font-size: 10.5px; font-weight: 700; }
.strength-label.strength-weak { color: #ef4444; }
.strength-label.strength-medium { color: #f59e0b; }
.strength-label.strength-strong { color: #10b981; }
/* 提交按钮 */
.submit-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
width: 100%; height: 44px; margin-top: 2px;
border: none; border-radius: 12px;
font-family: inherit; font-size: 14px; font-weight: 700;
color: white;
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #0891b2 100%);
box-shadow: 0 6px 20px -5px rgba(37, 99, 235, 0.4);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
}
.submit-btn:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 10px 26px -6px rgba(37, 99, 235, 0.46);
}
.submit-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.spin-icon { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* 底部标语 */
.fp-tagline {
font-family: 'Outfit', sans-serif;
font-size: 10px; color: rgba(99, 102, 241, 0.28);
letter-spacing: 2.5px; text-transform: uppercase;
font-weight: 600; margin: 0;
}
/* 响应式 */
@media (max-width: 500px) {
.fp-card { padding: 18px 18px 16px; border-radius: 20px; }
.fp-title { font-size: 16px; }
.code-btn { padding: 0 10px; font-size: 11px; }
} }
</style> </style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,405 +0,0 @@
<template>
<div class="page page--flush">
<div class="main-content-flat unified-shell">
<!-- 顶部状态切换 -->
<div class="approval-header unified-action-bar">
<div class="filter-form">
<div class="status-tabs">
<button
v-for="s in statusOptions"
:key="s.value"
class="status-tab"
:class="{ active: status === s.value }"
@click="status = s.value; loadUsers()"
>
<span class="tab-dot" :class="'dot--' + s.value.toLowerCase()"></span>
{{ s.label }}
<span v-if="s.value === 'PENDING' && pendingCount > 0" class="tab-badge">{{ pendingCount }}</span>
</button>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="approval-content">
<!-- 空状态 -->
<div v-if="!loading && users.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<p class="empty-title">{{ status === 'PENDING' ? '暂无待审批用户' : '暂无数据' }}</p>
<p class="empty-desc">{{ status === 'PENDING' ? '所有注册申请已处理完毕' : '当前筛选条件下没有用户' }}</p>
</div>
<!-- 用户卡片列表 -->
<div v-else class="user-cards" v-loading="loading">
<div v-for="user in users" :key="user.id" class="user-card" :class="'card--' + (user.status || '').toLowerCase()">
<div class="card-main">
<div class="card-avatar" :class="'avatar--' + (user.status || '').toLowerCase()">
{{ getInitials(user.full_name) }}
</div>
<div class="card-info">
<div class="card-name">{{ user.full_name }}</div>
<div class="card-email">{{ user.email }}</div>
<div class="card-meta">
<span class="meta-item" v-if="user.clinical_department">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-1 4h1"/>
</svg>
{{ user.clinical_department }}
</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{{ displayDateTime(user.created_at) }}
</span>
</div>
</div>
</div>
<div class="card-actions">
<template v-if="user.status === 'PENDING'">
<el-button type="success" @click="onApprove(user.id)" class="action-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ TEXT.modules.adminUserApproval.approve }}
</el-button>
<el-button type="danger" plain @click="onReject(user.id)" class="action-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
{{ TEXT.modules.adminUserApproval.reject }}
</el-button>
</template>
<template v-else>
<el-tag :type="statusType(user.status)" effect="light" class="status-tag">
{{ displayEnum(TEXT.enums.userStatus, user.status) }}
</el-tag>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { approveUser, listPendingUsers, rejectUser } from "../../api/admin";
import type { UserInfo, UserStatus } from "../../types/api";
import { displayDateTime, displayEnum } from "../../utils/display";
import { TEXT } from "../../locales";
const users = ref<UserInfo[]>([]);
const loading = ref(false);
const status = ref<UserStatus>("PENDING");
const pendingCount = ref(0);
const statusOptions = [
{ value: "PENDING" as UserStatus, label: TEXT.enums.userStatus.PENDING },
{ value: "ACTIVE" as UserStatus, label: TEXT.enums.userStatus.ACTIVE },
{ value: "REJECTED" as UserStatus, label: TEXT.enums.userStatus.REJECTED },
{ value: "DISABLED" as UserStatus, label: TEXT.enums.userStatus.DISABLED },
];
const getInitials = (name: string) => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
};
const statusType = (s: UserStatus) => {
switch (s) {
case "ACTIVE": return "success";
case "PENDING": return "warning";
case "REJECTED": return "danger";
default: return "info";
}
};
const loadUsers = async () => {
loading.value = true;
try {
const { data } = await listPendingUsers(status.value);
users.value = data.items || [];
if (status.value === "PENDING") {
pendingCount.value = users.value.length;
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.loadFailed);
} finally {
loading.value = false;
}
};
const loadPendingCount = async () => {
try {
const { data } = await listPendingUsers("PENDING");
pendingCount.value = (data.items || []).length;
} catch {
// skip
}
};
const onApprove = async (id: string) => {
try {
await approveUser(id);
ElMessage.success(TEXT.modules.adminUserApproval.approveSuccess);
loadUsers();
loadPendingCount();
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.approveFailed);
}
};
const onReject = async (id: string) => {
try {
await rejectUser(id);
ElMessage.success(TEXT.modules.adminUserApproval.rejectSuccess);
loadUsers();
loadPendingCount();
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.rejectFailed);
}
};
onMounted(() => {
loadUsers();
if (status.value !== "PENDING") {
loadPendingCount();
}
});
</script>
<style scoped>
.approval-header {
border-bottom: 1px solid var(--unified-shell-divider);
}
.status-tabs {
display: flex;
gap: 4px;
}
.status-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
color: var(--ctms-text-secondary);
cursor: pointer;
transition: var(--ctms-transition);
}
.status-tab:hover {
background: var(--ctms-neutral-100);
color: var(--ctms-text-main);
}
.status-tab.active {
background: var(--ctms-primary-light);
color: var(--ctms-primary);
}
.tab-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.tab-dot.dot--pending { color: var(--ctms-warning); }
.tab-dot.dot--active { color: var(--ctms-success); }
.tab-dot.dot--rejected { color: var(--ctms-danger); }
.tab-dot.dot--disabled { color: var(--ctms-text-disabled); }
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--ctms-danger);
color: #fff;
font-size: 11px;
font-weight: 700;
}
.approval-content {
padding: 16px;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
}
.empty-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: #e6f4ed;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.empty-icon svg {
width: 32px;
height: 32px;
color: var(--ctms-success);
}
.empty-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0 0 4px;
}
.empty-desc {
font-size: 13px;
color: var(--ctms-text-secondary);
margin: 0;
}
/* 用户卡片 */
.user-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
background: #fff;
transition: var(--ctms-transition);
}
.user-card:hover {
border-color: var(--ctms-border-color-hover);
box-shadow: var(--ctms-shadow-sm);
}
.user-card.card--pending {
border-left: 3px solid var(--ctms-warning);
}
.card-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex: 1;
}
.card-avatar {
width: 42px;
height: 42px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
background: var(--ctms-primary);
}
.card-avatar.avatar--active { background: linear-gradient(135deg, #3f8f6b, #2d7a5a); }
.card-avatar.avatar--pending { background: linear-gradient(135deg, #c58b2a, #a87420); }
.card-avatar.avatar--disabled { background: linear-gradient(135deg, #94a3b8, #64748b); }
.card-avatar.avatar--rejected { background: linear-gradient(135deg, #c24b4b, #a83a3a); }
.card-info {
min-width: 0;
}
.card-name {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.card-email {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 1px;
}
.card-meta {
display: flex;
gap: 12px;
margin-top: 6px;
flex-wrap: wrap;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.meta-item svg {
opacity: 0.6;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-tag {
font-weight: 600;
}
@media (max-width: 640px) {
.user-card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.card-actions {
margin-left: 56px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+96 -109
View File
@@ -33,59 +33,32 @@
</div> </div>
</div> </div>
<div class="self-service-panel">
<div class="self-service-copy">
<span class="self-service-title">{{ TEXT.modules.adminUsers.selfServiceResetTitle }}</span>
<span class="self-service-desc">{{ TEXT.modules.adminUsers.selfServiceResetDesc }}</span>
</div>
<el-button class="reset-code-send-btn" type="primary" :loading="sendingCode" @click="sendResetCode">
{{ TEXT.modules.adminUsers.sendResetCode }}
</el-button>
</div>
<div class="emergency-divider">
<span>{{ TEXT.modules.adminUsers.emergencyResetDivider }}</span>
</div>
<el-form ref="formRef" :model="form" label-width="100px" :rules="rules" class="reset-form"> <el-form ref="formRef" :model="form" label-width="100px" :rules="rules" class="reset-form">
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername"> <el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
<el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" /> <el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode"> <el-form-item :label="TEXT.modules.adminUsers.emergencyPassword" prop="manualPassword">
<div class="mode-cards">
<div class="mode-card" :class="{ active: form.mode === 'auto' }" @click="form.mode = 'auto'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordAuto }}</span>
</div>
<div class="mode-card" :class="{ active: form.mode === 'manual' }" @click="form.mode = 'manual'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordManual }}</span>
</div>
</div>
</el-form-item>
<el-form-item
:label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword"
:prop="form.mode === 'auto' ? 'tempPassword' : 'manualPassword'"
>
<el-input <el-input
v-if="form.mode === 'auto'"
v-model="form.tempPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-temp-password"
readonly
>
<template #append>
<el-button @click="regenerate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ TEXT.modules.adminUsers.regenerate }}
</el-button>
</template>
</el-input>
<el-input
v-else
v-model="form.manualPassword" v-model="form.manualPassword"
type="password" type="password"
show-password show-password
autocomplete="new-password" autocomplete="new-password"
name="ctms-reset-manual-password" name="ctms-reset-manual-password"
:placeholder="TEXT.modules.adminUsers.manualPasswordHint" :placeholder="TEXT.modules.adminUsers.emergencyPasswordHint"
/> />
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div> <div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
</el-form-item> </el-form-item>
@@ -109,6 +82,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { sendPasswordResetLink } from "../../api/auth";
import { updateUser } from "../../api/users"; import { updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api"; import type { UserInfo } from "../../types/api";
import { TEXT, requiredMessage } from "../../locales"; import { TEXT, requiredMessage } from "../../locales";
@@ -130,10 +104,9 @@ const visibleProxy = computed({
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const submitting = ref(false); const submitting = ref(false);
const sendingCode = ref(false);
const form = reactive({ const form = reactive({
confirmUsername: "", confirmUsername: "",
mode: "auto",
tempPassword: "",
manualPassword: "", manualPassword: "",
}); });
@@ -146,30 +119,17 @@ const getInitials = (name: string) => {
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }], confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
tempPassword: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "auto" && !form.tempPassword) {
callback(new Error(TEXT.modules.adminUsers.tempPasswordError));
return;
}
if (form.mode === "manual" && !form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return;
}
callback();
},
trigger: "blur",
},
],
manualPassword: [ manualPassword: [
{ {
validator: (_rule, _value, callback) => { validator: (_rule, _value, callback) => {
if (form.mode === "manual" && !form.manualPassword) { if (!form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired)); callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return; return;
} }
if (!/^(?=.*[A-Za-z])(?=.*\d).{8,}$/.test(form.manualPassword)) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRule));
return;
}
callback(); callback();
}, },
trigger: "blur", trigger: "blur",
@@ -177,19 +137,8 @@ const rules = reactive<FormRules>({
], ],
}); });
const generatePassword = () => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*";
let pwd = "";
for (let i = 0; i < 12; i++) {
pwd += chars.charAt(Math.floor(Math.random() * chars.length));
}
form.tempPassword = pwd;
};
const regenerate = () => generatePassword();
const clearPasswordValidation = () => { const clearPasswordValidation = () => {
formRef.value?.clearValidate(["tempPassword", "manualPassword"]); formRef.value?.clearValidate(["manualPassword"]);
}; };
watch( watch(
@@ -197,20 +146,26 @@ watch(
(val) => { (val) => {
if (val) { if (val) {
form.confirmUsername = ""; form.confirmUsername = "";
form.mode = "auto";
generatePassword();
form.manualPassword = ""; form.manualPassword = "";
clearPasswordValidation(); clearPasswordValidation();
} }
} }
); );
watch( const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username && !!form.manualPassword);
() => form.mode,
() => { clearPasswordValidation(); }
);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username); const sendResetCode = async () => {
if (!props.user?.email) return;
sendingCode.value = true;
try {
await sendPasswordResetLink(props.user.email);
ElMessage.success(TEXT.modules.adminUsers.sendResetCodeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.modules.adminUsers.sendResetCodeFailed);
} finally {
sendingCode.value = false;
}
};
const onSubmit = async () => { const onSubmit = async () => {
if (!props.user) return; if (!props.user) return;
@@ -220,7 +175,7 @@ const onSubmit = async () => {
return; return;
} }
const ok = await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(
TEXT.modules.adminUsers.resetConfirmPrompt, TEXT.modules.adminUsers.emergencyResetConfirmPrompt,
TEXT.modules.adminUsers.resetConfirmTitle, TEXT.modules.adminUsers.resetConfirmTitle,
{ {
type: "warning", type: "warning",
@@ -231,8 +186,7 @@ const onSubmit = async () => {
if (!ok) return; if (!ok) return;
submitting.value = true; submitting.value = true;
try { try {
const password = form.mode === "manual" ? form.manualPassword : form.tempPassword; await updateUser(props.user.id, { password: form.manualPassword });
await updateUser(props.user.id, { password });
ElMessage.success(TEXT.modules.adminUsers.resetSuccess); ElMessage.success(TEXT.modules.adminUsers.resetSuccess);
emit("reset"); emit("reset");
visibleProxy.value = false; visibleProxy.value = false;
@@ -319,44 +273,77 @@ const onSubmit = async () => {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
} }
.reset-form :deep(.el-form-item) { .self-service-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
margin-bottom: 16px; margin-bottom: 16px;
} }
.mode-cards { .self-service-copy {
display: flex;
gap: 10px;
width: 100%;
}
.mode-card {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 3px;
gap: 6px; min-width: 0;
padding: 12px;
border-radius: 10px;
border: 2px solid var(--ctms-border-color);
cursor: pointer;
transition: var(--ctms-transition);
color: var(--ctms-text-secondary);
} }
.mode-card:hover { .self-service-title {
border-color: var(--ctms-border-color-hover); font-size: 13px;
background: var(--ctms-neutral-100); font-weight: 700;
color: #1e40af;
} }
.mode-card.active { .self-service-desc {
font-size: 12px;
line-height: 1.5;
color: #3b5f9a;
}
.reset-code-send-btn {
flex: 0 0 auto;
min-width: 118px;
height: 38px;
color: #fff;
background: var(--ctms-primary);
border-color: var(--ctms-primary); border-color: var(--ctms-primary);
background: var(--ctms-primary-light);
color: var(--ctms-primary);
} }
.mode-card span { .reset-code-send-btn:hover,
.reset-code-send-btn:focus {
color: #fff;
background: var(--ctms-primary-hover);
border-color: var(--ctms-primary-hover);
}
.reset-code-send-btn :deep(span) {
color: inherit;
}
.emergency-divider {
display: flex;
align-items: center;
gap: 12px;
color: var(--ctms-text-tertiary);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
margin: 2px 0 16px;
}
.emergency-divider::before,
.emergency-divider::after {
content: "";
height: 1px;
flex: 1;
background: var(--ctms-border-color);
}
.reset-form :deep(.el-form-item) {
margin-bottom: 16px;
} }
.password-hint { .password-hint {
+1 -42
View File
@@ -29,18 +29,6 @@
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span> <span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card--pending">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ pendingCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.PENDING }}</span>
</div>
</div>
<div class="stat-card stat-card--disabled"> <div class="stat-card stat-card--disabled">
<div class="stat-icon"> <div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
@@ -70,9 +58,7 @@
</div> </div>
<div class="filter-item-form"> <div class="filter-item-form">
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="applyFilters"> <el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="applyFilters">
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" /> <el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" /> <el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select> </el-select>
</div> </div>
@@ -189,23 +175,11 @@ const auth = useAuthStore();
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null; let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length); const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
const pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length); const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
const statusType = (status: string) => {
switch (status) {
case "ACTIVE": return "success";
case "PENDING": return "warning";
case "REJECTED": return "danger";
default: return "info";
}
};
const statusLabel = (status: string) => { const statusLabel = (status: string) => {
switch (status) { switch (status) {
case "ACTIVE": return TEXT.enums.userStatus.ACTIVE; case "ACTIVE": return TEXT.enums.userStatus.ACTIVE;
case "PENDING": return TEXT.enums.userStatus.PENDING;
case "REJECTED": return TEXT.enums.userStatus.REJECTED;
case "DISABLED": return TEXT.enums.userStatus.DISABLED; case "DISABLED": return TEXT.enums.userStatus.DISABLED;
default: return status || TEXT.common.fallback; default: return status || TEXT.common.fallback;
} }
@@ -378,7 +352,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 10px; gap: 10px;
padding: 10px 16px; padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider); border-bottom: 1px solid var(--unified-shell-divider);
@@ -424,11 +398,6 @@ onBeforeUnmount(() => {
color: var(--ctms-success); color: var(--ctms-success);
} }
.stat-card--pending .stat-icon {
background: #fef3e2;
color: var(--ctms-warning);
}
.stat-card--disabled .stat-icon { .stat-card--disabled .stat-icon {
background: #fde8e8; background: #fde8e8;
color: var(--ctms-danger); color: var(--ctms-danger);
@@ -517,21 +486,11 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 3px rgba(63, 143, 107, 0.15); box-shadow: 0 0 0 3px rgba(63, 143, 107, 0.15);
} }
.status-dot.dot--pending {
background: var(--ctms-warning);
box-shadow: 0 0 0 3px rgba(197, 139, 42, 0.15);
}
.status-dot.dot--disabled { .status-dot.dot--disabled {
background: var(--ctms-text-disabled); background: var(--ctms-text-disabled);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15); box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
} }
.status-dot.dot--rejected {
background: var(--ctms-danger);
box-shadow: 0 0 0 3px rgba(194, 75, 75, 0.15);
}
.text-muted { .text-muted {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-size: 12px; font-size: 12px;