diff --git a/README.md b/README.md index 937e83e6..5ad448b7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # CTMS 项目快速上手 -## 账号(示例) -- ADMIN:`admin / 123456` -- PM:`pm1 / 123456` -- CRA:`cra1 / 123456` - -> 如需更多账号,可在前端 `/admin/users` 由 ADMIN 创建,并在项目成员中赋予角色。 +## 账号与注册 +- 初始化管理员:`admin@example.com / admin123`(已修正邮箱域名,后端启动时自动创建) +- 自助注册:前端 `/register` 提交邮箱、密码、姓名、角色(CRA/PV/IMP/PM)、部门,状态为 PENDING。 +- 管理员审核:`/admin/user-approval` 列表查看待审核,支持通过/拒绝;仅 ACTIVE 用户可登录。 +- 更多账号:管理员也可在 `/admin/users` 直接创建 ACTIVE 账号,再在项目成员中赋予角色。 ## 角色权限概要(前端操作级提示,后端仍最终裁决) - ADMIN:全权限 @@ -20,7 +19,7 @@ - 后端 API:同域 `/api/v1/*`(已在前端代理) ## 常用流程 -1. 用 `admin / Password123!` 登录前端。 +1. 用管理员账号登录前端(默认 `admin@example.com / admin123`)。 2. 进入左侧导航「账号治理」「项目治理」进行用户、项目、中心等全局管理;或在“项目列表”选择项目后进入项目内工作区。 3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。 4. 退出登录或切换项目可通过顶部右侧下拉。 @@ -33,5 +32,6 @@ > ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。 ## 注意 +- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。 - 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。 - 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。 diff --git a/backend/alembic/versions/20240501_000001_create_users_table.py b/backend/alembic/versions/20240501_000001_create_users_table.py new file mode 100644 index 00000000..f052340c --- /dev/null +++ b/backend/alembic/versions/20240501_000001_create_users_table.py @@ -0,0 +1,50 @@ +"""create users table with review status""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "20240501_000001" +down_revision = None +branch_labels = None +depends_on = None + + +user_role_enum = sa.Enum("ADMIN", "PM", "CRA", "PV", "IMP", name="user_role") +user_status_enum = sa.Enum("PENDING", "ACTIVE", "REJECTED", "DISABLED", name="user_status") + + +def upgrade() -> None: + bind = op.get_bind() + user_role_enum.create(bind, checkfirst=True) + user_status_enum.create(bind, checkfirst=True) + op.create_table( + "users", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=False), + sa.Column("role", user_role_enum, nullable=False), + sa.Column("department", sa.String(length=255), nullable=False), + sa.Column("status", user_status_enum, nullable=False, server_default="PENDING"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("approved_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True), + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") + bind = op.get_bind() + user_role_enum.drop(bind, checkfirst=True) + user_status_enum.drop(bind, checkfirst=True) diff --git a/backend/alembic/versions/20240501_000002_add_avatar_to_users.py b/backend/alembic/versions/20240501_000002_add_avatar_to_users.py new file mode 100644 index 00000000..a9df55a5 --- /dev/null +++ b/backend/alembic/versions/20240501_000002_add_avatar_to_users.py @@ -0,0 +1,19 @@ +"""add avatar_url to users""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20240501_000002" +down_revision = "20240501_000001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("avatar_url", sa.String(length=500), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "avatar_url") diff --git a/backend/app/api/v1/admin_users.py b/backend/app/api/v1/admin_users.py new file mode 100644 index 00000000..840fccf8 --- /dev/null +++ b/backend/app/api/v1/admin_users.py @@ -0,0 +1,62 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db_session, require_roles +from app.crud import user as user_crud +from app.models.user import User, UserRole, 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([UserRole.ADMIN.value])), +) -> 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="User not found") + if user.role == UserRole.ADMIN: + 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([UserRole.ADMIN.value])), +) -> 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([UserRole.ADMIN.value])), +) -> 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 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index b0d8ccb7..7ae39326 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,40 +1,66 @@ from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field +from fastapi import File, UploadFile +from pydantic import BaseModel, EmailStr, Field from sqlalchemy.ext.asyncio import AsyncSession +from pathlib import Path +import uuid from app.core.security import create_access_token, verify_password from app.core.deps import get_current_user, get_db_session from app.crud import user as user_crud -from app.schemas.user import Token, UserRead +from app.models.user import UserRole, UserStatus +from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate +from fastapi.responses import FileResponse class LoginRequest(BaseModel): - username: str = Field(min_length=1) + email: EmailStr password: str = Field(min_length=1) router = APIRouter() +AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" +AVATAR_ROOT.mkdir(parents=True, exist_ok=True) + + +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register( + payload: UserRegisterRequest, + db: AsyncSession = Depends(get_db_session), +): + if payload.role == UserRole.ADMIN.value: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许注册管理员账号") + existing = await user_crud.get_by_email(db, payload.email) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册") + await user_crud.create_pending_user(db, payload) + return {"message": "注册成功,等待管理员审核"} @router.post("/login", response_model=Token) async def login_for_access_token( payload: LoginRequest, db: AsyncSession = Depends(get_db_session) ) -> Token: - db_user = await user_crud.get_by_username(db, payload.username) - if not db_user or not verify_password(payload.password, db_user.hashed_password): + db_user = await user_crud.get_by_email(db, payload.email) + if not db_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", + detail="账号不存在", ) - if not db_user.is_active: + if not verify_password(payload.password, db_user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Inactive user", + detail="密码错误", + ) + if db_user.status != UserStatus.ACTIVE: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="账号未审核或不可用", ) access_token = create_access_token( user_id=str(db_user.id), - role=db_user.role, + role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, expires_minutes=None, ) return Token(access_token=access_token, token_type="bearer") @@ -43,3 +69,60 @@ async def login_for_access_token( @router.get("/me", response_model=UserRead) async def read_me(current_user=Depends(get_current_user)) -> UserRead: return current_user + + +@router.patch("/me", response_model=UserRead) +async def update_me( + payload: UserSelfUpdate, + current_user=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), +) -> UserRead: + if payload.password: + if not payload.current_password or not verify_password(payload.current_password, current_user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确") + update_data = { + "full_name": payload.full_name if payload.full_name is not None else current_user.full_name, + "department": payload.department if payload.department is not None else current_user.department, + "password": payload.password if payload.password else None, + "avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url, + } + updated = await user_crud.update_user(db, current_user, UserUpdate(**update_data)) + return updated + + +@router.post("/me/avatar", response_model=UserRead) +async def upload_avatar( + file: UploadFile = File(...), + current_user=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), +) -> UserRead: + AVATAR_ROOT.mkdir(parents=True, exist_ok=True) + user_dir = AVATAR_ROOT / str(current_user.id) + user_dir.mkdir(parents=True, exist_ok=True) + ext = Path(file.filename).suffix or ".png" + filename = f"{uuid.uuid4()}{ext}" + dest = user_dir / filename + content = await file.read() + dest.write_bytes(content) + # Optionally clean old files + for old in user_dir.iterdir(): + if old.name != filename: + try: + old.unlink() + except OSError: + pass + rel_url = f"/api/v1/auth/avatar/{current_user.id}/{filename}" + updated = await user_crud.update_user( + db, + current_user, + UserUpdate(avatar_url=rel_url), + ) + return updated + + +@router.get("/avatar/{user_id}/{filename}") +async def get_avatar(user_id: str, filename: str): + file_path = AVATAR_ROOT / user_id / filename + if not file_path.exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found") + return FileResponse(file_path) diff --git a/backend/app/api/v1/comments.py b/backend/app/api/v1/comments.py index 6ca4f015..53ac90a9 100644 --- a/backend/app/api/v1/comments.py +++ b/backend/app/api/v1/comments.py @@ -48,7 +48,7 @@ async def create_comment( entity_type=entity_type, entity_id=entity_id, action="CREATE_COMMENT", - detail=f"Comment created by {current_user.username}", + detail=f"Comment created by {current_user.full_name}", operator_id=current_user.id, operator_role=current_user.role, ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index ce56bef0..3ecb4006 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants +from app.api.v1 import auth, users, admin_users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants api_router = APIRouter() 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(users.router, prefix="/users", tags=["users"]) api_router.include_router(studies.router, prefix="/studies", tags=["studies"]) api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"]) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 1184f67d..e110d1ff 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -32,11 +32,11 @@ async def create_user( db: AsyncSession = Depends(get_db_session), current_user=Depends(require_roles(["ADMIN"])), ) -> UserRead: - existing = await user_crud.get_by_username(db, user_in.username) + existing = await user_crud.get_by_email(db, user_in.email) if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Username already exists", + detail="Email already exists", ) user = await user_crud.create_user(db, user_in) return user @@ -64,7 +64,7 @@ async def delete_user( current_user=Depends(require_roles(["ADMIN"])), ): admin_password = payload.get("admin_password") - if not admin_password or not verify_password(admin_password, current_user.hashed_password): + if not admin_password or not verify_password(admin_password, current_user.password_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误") db_user = await user_crud.get_by_id(db, user_id) if not db_user: diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index c9d5b475..7d92883b 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -46,7 +46,8 @@ def require_roles(roles: Iterable[str]) -> Callable: roles_set = set(roles) async def dependency(current_user=Depends(get_current_user)): - if current_user.role not in roles_set: + current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) + if current_role not in roles_set: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions", @@ -61,7 +62,8 @@ async def get_study_member( current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - if current_user.role == "ADMIN": + role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + if role_value == "ADMIN": return None return await member_crud.get_member(db, study_id, current_user.id) @@ -72,7 +74,8 @@ def require_study_member(): current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - if current_user.role == "ADMIN": + role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + if role_value == "ADMIN": return current_user membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: @@ -94,7 +97,8 @@ def require_study_roles(roles: Iterable[str]): current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - if current_user.role == "ADMIN": + role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + if role_value == "ADMIN": return current_user membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active or membership.role_in_study not in roles_set: diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 1acdbd67..8afc72b8 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -1,17 +1,17 @@ import uuid from typing import Sequence -from sqlalchemy import select, update, delete +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import hash_password -from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate from app.models.study_member import StudyMember +from app.models.user import User, UserRole, UserStatus +from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate -async def get_by_username(db: AsyncSession, username: str) -> User | None: - result = await db.execute(select(User).where(User.username == username)) +async def get_by_email(db: AsyncSession, email: str) -> User | None: + result = await db.execute(select(User).where(User.email == email)) return result.scalar_one_or_none() @@ -20,12 +20,17 @@ async def get_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None: return result.scalar_one_or_none() -async def create_user(db: AsyncSession, user_in: UserCreate) -> User: +async def create_user( + db: AsyncSession, user_in: UserCreate, *, status: UserStatus | None = UserStatus.ACTIVE +) -> User: + status_value = status or UserStatus(user_in.status) user = User( - username=user_in.username, - hashed_password=hash_password(user_in.password), - role=user_in.role, - is_active=True, + email=user_in.email, + password_hash=hash_password(user_in.password), + full_name=user_in.full_name, + role=UserRole(user_in.role), + department=user_in.department, + status=status_value, ) db.add(user) await db.commit() @@ -33,21 +38,29 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> User: return user +async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User: + return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING) + + async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User: update_data = {} if user_in.role is not None: - update_data["role"] = user_in.role + update_data["role"] = UserRole(user_in.role) + if user_in.status is not None: + update_data["status"] = UserStatus(user_in.status) if user_in.is_active is not None: - update_data["is_active"] = user_in.is_active + update_data["status"] = UserStatus.ACTIVE if user_in.is_active else UserStatus.DISABLED + if user_in.avatar_url is not None: + update_data["avatar_url"] = user_in.avatar_url if user_in.password: - update_data["hashed_password"] = hash_password(user_in.password) + update_data["password_hash"] = hash_password(user_in.password) + if user_in.full_name is not None: + update_data["full_name"] = user_in.full_name + if user_in.department is not None: + update_data["department"] = user_in.department if update_data: - await db.execute( - update(User) - .where(User.id == user.id) - .values(**update_data) - ) + await db.execute(update(User).where(User.id == user.id).values(**update_data)) await db.commit() await db.refresh(user) return user @@ -58,16 +71,28 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque return result.scalars().all() +async def list_users_by_status( + db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100 +) -> Sequence[User]: + stmt = select(User).offset(skip).limit(limit) + if status: + stmt = stmt.where(User.status == status) + result = await db.execute(stmt.order_by(User.created_at.desc())) + return result.scalars().all() + + async def ensure_admin_exists(db: AsyncSession, *, default_password: str = "admin123") -> None: - result = await db.execute(select(User).where(User.username == "admin")) + result = await db.execute(select(User).where(User.role == UserRole.ADMIN)) admin = result.scalar_one_or_none() if admin: return new_admin = User( - username="admin", - hashed_password=hash_password(default_password), - role="ADMIN", - is_active=True, + email="admin@ctms.local", + password_hash=hash_password(default_password), + full_name="System Admin", + role=UserRole.ADMIN, + department="SYSTEM", + status=UserStatus.ACTIVE, ) db.add(new_admin) await db.commit() @@ -85,3 +110,25 @@ async def delete_user(db: AsyncSession, user: User) -> None: await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id)) await db.delete(user) 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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 96851aa2..ece5f1e9 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,19 +1,67 @@ +import enum import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy import DateTime, Enum, ForeignKey, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.db.base_class import Base +class UserRole(str, enum.Enum): + ADMIN = "ADMIN" + PM = "PM" + CRA = "CRA" + PV = "PV" + IMP = "IMP" + + +class UserStatus(str, enum.Enum): + PENDING = "PENDING" + ACTIVE = "ACTIVE" + REJECTED = "REJECTED" + DISABLED = "DISABLED" + + class User(Base): __tablename__ = "users" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) - role: Mapped[str] = mapped_column(String(20), nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False) + department: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[UserStatus] = mapped_column( + Enum(UserStatus, name="user_status"), + nullable=False, + server_default=UserStatus.PENDING.value, + default=UserStatus.PENDING, + ) + 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(), + ) + approved_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True, + ) + approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + + @property + def is_active(self) -> bool: # compatibility helper for existing checks + return self.status == UserStatus.ACTIVE + + @property + def username(self) -> str: # backward compatibility for existing UI copy + return self.email diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 93f95b23..064555d5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,54 +1,104 @@ +import re import uuid from datetime import datetime from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"] +RegisterRole = Literal["PM", "CRA", "PV", "IMP"] +UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] + +PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$") class UserDisplay(BaseModel): - id: uuid.UUID - username: str - display_name: Optional[str] = None - - model_config = ConfigDict(from_attributes=True) - - -class UserBase(BaseModel): - username: str = Field(min_length=1) + id: uuid.UUID + email: EmailStr + full_name: str role: UserRole - is_active: bool = True + avatar_url: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) -class UserCreate(BaseModel): - username: str = Field(min_length=1) - password: str = Field(min_length=1) +class _PasswordValidator(BaseModel): + password: Optional[str] = Field(default=None, min_length=8) + + @field_validator("password") + @classmethod + def validate_password_strength(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if not PASSWORD_REGEX.match(v): + raise ValueError("密码需至少 8 位且包含字母和数字") + return v + + +class UserRegisterRequest(_PasswordValidator): + password: str = Field(min_length=8) + email: EmailStr + full_name: str = Field(min_length=1) + role: RegisterRole + department: str = Field(min_length=1) + + +class UserCreate(_PasswordValidator): + password: str = Field(min_length=8) + email: EmailStr + full_name: str = Field(min_length=1) role: UserRole + department: str = Field(min_length=1) + status: UserStatus = "ACTIVE" class UserRead(BaseModel): id: uuid.UUID + email: EmailStr username: str + full_name: str role: UserRole + department: str + status: UserStatus is_active: bool created_at: datetime + approved_at: Optional[datetime] = None + approved_by: Optional[uuid.UUID] = None + avatar_url: Optional[str] = None model_config = ConfigDict(from_attributes=True) -class UserUpdate(BaseModel): +class UserUpdate(_PasswordValidator): + email: Optional[EmailStr] = None + full_name: Optional[str] = None role: Optional[UserRole] = None + department: Optional[str] = None + status: Optional[UserStatus] = None + password: Optional[str] = None is_active: Optional[bool] = None - password: Optional[str] = Field(default=None, min_length=1) + avatar_url: Optional[str] = None -class UserInDB(UserBase): - id: uuid.UUID - hashed_password: str - created_at: datetime +class UserResponse(UserRead): + pass - model_config = ConfigDict(from_attributes=True) + +class UserAdminReviewRequest(BaseModel): + action: Literal["approve", "reject"] + + +class AdminUserListResponse(BaseModel): + items: list[UserResponse] + total: int + + +class UserSelfUpdate(_PasswordValidator): + full_name: Optional[str] = None + department: Optional[str] = None + password: Optional[str] = None + current_password: Optional[str] = None + avatar_url: Optional[str] = None class Token(BaseModel): diff --git a/backend/requirements.txt b/backend/requirements.txt index b5051fe4..49b58b76 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,7 @@ bcrypt==4.0.1 python-multipart==0.0.6 aiofiles==23.2.1 debugpy==1.8.0 +pytest==8.1.1 +pytest-asyncio==0.23.5 +httpx==0.25.2 +email-validator==2.1.1 diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py new file mode 100644 index 00000000..1921fe1b --- /dev/null +++ b/backend/tests/test_registration.py @@ -0,0 +1,119 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.main import create_app +from app.core.deps import get_db_session +from app.core.security import hash_password +from app.crud import user as user_crud +from app.models.user import User, UserRole, UserStatus + +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest_asyncio.fixture +async def client_and_db(): + engine = create_async_engine(TEST_DATABASE_URL, future=True) + TestSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + async with engine.begin() as conn: + await conn.run_sync(User.__table__.create) + + async def override_get_db(): + async with TestSessionLocal() as session: + yield session + + app = create_app() + app.dependency_overrides[get_db_session] = override_get_db + + async with TestSessionLocal() as session: + admin = User( + email="admin@test.com", + password_hash=hash_password("admin123"), + full_name="Admin", + department="Admin", + role=UserRole.ADMIN, + status=UserStatus.ACTIVE, + ) + session.add(admin) + await session.commit() + + async with AsyncClient(app=app, base_url="http://test") as client: + yield client, TestSessionLocal + + await engine.dispose() + + +@pytest.mark.asyncio +async def test_register_creates_pending_user(client_and_db): + client, SessionLocal = client_and_db + payload = { + "email": "newuser@test.com", + "password": "Password123", + "full_name": "New User", + "role": "CRA", + "department": "Clinical", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code == 201 + async with SessionLocal() as session: + user = await user_crud.get_by_email(session, payload["email"]) + assert user is not None + assert user.status == UserStatus.PENDING + + +@pytest.mark.asyncio +async def test_login_blocked_before_approval(client_and_db): + client, SessionLocal = client_and_db + payload = { + "email": "pending@test.com", + "password": "Password123", + "full_name": "Pending User", + "role": "PV", + "department": "Safety", + } + await client.post("/api/v1/auth/register", json=payload) + resp = await client.post("/api/v1/auth/login", json={"email": payload["email"], "password": payload["password"]}) + assert resp.status_code == 401 + assert "账号未审核" in resp.json().get("detail", "") + + +@pytest.mark.asyncio +async def test_admin_can_approve_user(client_and_db): + client, SessionLocal = client_and_db + payload = { + "email": "approve@test.com", + "password": "Password123", + "full_name": "Approve Target", + "role": "IMP", + "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 client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"}) + token = admin_login.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.post(f"/api/v1/admin/users/{user_id}/approve", json={"action": "approve"}, headers=headers) + assert resp.status_code == 200 + async with SessionLocal() as session: + refreshed = await user_crud.get_by_email(session, payload["email"]) + assert refreshed.status == UserStatus.ACTIVE + assert refreshed.approved_by is not None + + +@pytest.mark.asyncio +async def test_admin_role_cannot_register(client_and_db): + client, _ = client_and_db + payload = { + "email": "admin-register@test.com", + "password": "Password123", + "full_name": "Bad Admin", + "role": "ADMIN", + "department": "IT", + } + resp = await client.post("/api/v1/auth/register", json=payload) + assert resp.status_code in (400, 422) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 00000000..e9e0ba07 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,12 @@ +import type { AxiosResponse } from "axios"; +import { apiGet, apiPost } from "./axios"; +import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api"; + +export const listPendingUsers = (status: UserStatus = "PENDING"): Promise> => + apiGet("/api/v1/admin/users", { params: { status } }); + +export const approveUser = (userId: string): Promise> => + apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" }); + +export const rejectUser = (userId: string): Promise> => + apiPost(`/api/v1/admin/users/${userId}/reject`, { action: "reject" }); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 4cbfc072..937ff9eb 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,10 +1,20 @@ import type { AxiosResponse } from "axios"; -import api, { apiGet, apiPost } from "./axios"; -import type { UserMeResponse, LoginRequest, LoginResponse } from "../types/api"; +import api, { apiGet, apiPatch, apiPost } from "./axios"; +import type { UserMeResponse, LoginRequest, LoginResponse, RegisterRequest } from "../types/api"; export const login = (payload: LoginRequest): Promise> => apiPost("/api/v1/auth/login", payload); export const fetchMe = (): Promise> => apiGet("/api/v1/auth/me"); +export const register = (payload: RegisterRequest): Promise> => + apiPost("/api/v1/auth/register", payload); + +export const updateProfile = (payload: { + full_name?: string; + department?: string; + current_password?: string; + password?: string; +}) => apiPatch("/api/v1/auth/me", payload); + export default api; diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index f4e9bb5d..d6dcf8e5 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -27,6 +27,12 @@ instance.interceptors.response.use( async (error: AxiosError) => { const status = error.response?.status; const data = error.response?.data; + const reqUrl = error.config?.url || ""; + const isAuthEndpoint = reqUrl.includes("/api/v1/auth/login") || reqUrl.includes("/api/v1/auth/register"); + if (isAuthEndpoint) { + // 认证相关的错误由具体页面自行处理,避免重复提示 + return Promise.reject(error); + } // 401 统一处理:同步清理本地 token,并防止重复弹窗 const handleUnauthorized = () => { const auth = useAuthStore(); diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 91806782..cee787b0 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -5,10 +5,19 @@ import { apiDelete } from "./axios"; export const fetchUsers = (params?: Record) => apiGet>("/api/v1/users", { params }); -export const createUser = (payload: { username: string; password: string; role: string }) => +export const createUser = (payload: { + email: string; + password: string; + full_name: string; + role: string; + department: string; +}) => apiPost("/api/v1/users", payload); -export const updateUser = (userId: string, payload: Partial<{ role: string; is_active: boolean; password: string }>) => +export const updateUser = ( + userId: string, + payload: Partial<{ role: string; status: string; password: string; full_name: string; department: string; is_active: boolean }> +) => apiPatch(`/api/v1/users/${userId}`, payload); export const deleteUser = (userId: string, payload: { admin_password: string }) => diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 4312707c..8a766776 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -39,7 +39,7 @@ - 里程碑管理 + 伦理与启动 @@ -94,14 +94,28 @@
- +
@@ -110,6 +124,7 @@ + @@ -123,7 +138,7 @@ import StudySelector from "./StudySelector.vue"; import { Monitor, User, Suitcase, List, House, Timer, UserFilled, Warning, InfoFilled, QuestionFilled, Checked, Management, - Money, Collection, Memo + Money, Collection, Memo, ArrowDown, SwitchButton } from "@element-plus/icons-vue"; const auth = useAuthStore(); @@ -137,6 +152,15 @@ const onLogout = () => { study.clearCurrentStudy(); router.replace("/login"); }; + +const onCommand = (cmd: string) => { + if (cmd === "logout") { + onLogout(); + } else if (cmd === "profile") { + // 预留个人设置路由占位,如后续有页面则跳转 + router.push("/profile"); + } +}; diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue new file mode 100644 index 00000000..3e2616b2 --- /dev/null +++ b/frontend/src/views/Register.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/frontend/src/views/admin/AdminUserApproval.vue b/frontend/src/views/admin/AdminUserApproval.vue new file mode 100644 index 00000000..54124070 --- /dev/null +++ b/frontend/src/views/admin/AdminUserApproval.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/src/views/admin/UserForm.vue b/frontend/src/views/admin/UserForm.vue index eeda2332..6aa41a70 100644 --- a/frontend/src/views/admin/UserForm.vue +++ b/frontend/src/views/admin/UserForm.vue @@ -1,8 +1,14 @@