Compare commits
4 Commits
f9a9f7eafe
...
b25055775e
| Author | SHA1 | Date | |
|---|---|---|---|
| b25055775e | |||
| 6c2bcc59b2 | |||
| e242e067f0 | |||
| c7ca97a6d6 |
@@ -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": "测试邮件已发送"}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 通常使用 465,STARTTLS 通常使用 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="请先完成邮箱验证码校验")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
// 权限管理子页,供「权限管理」父级与末级横跳
|
// 权限管理子页,供「权限管理」父级与末级横跳
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const privacyPolicySections: AuthProtocolSection[] = [
|
|||||||
{
|
{
|
||||||
title: "2. 数据使用与最小必要",
|
title: "2. 数据使用与最小必要",
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
"平台仅在账号注册审核、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
|
"平台仅在账号注册、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
|
||||||
"用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。",
|
"用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+993
-409
File diff suppressed because it is too large
Load Diff
+1201
-844
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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user