移除用户系统级角色,权限完全迁移至项目级管理

- 用户注册/创建不再需要选择角色,账号管理只管资料和状态
- 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员
- PM(项目负责人)角色自动拥有全部项目权限且不可修改
- 系统管理员在项目中的成员身份不可被删除
- 管理界面全面美化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 15:52:17 +08:00
parent 6cefa620e4
commit 5b97ea32ab
35 changed files with 1937 additions and 481 deletions
-2
View File
@@ -89,8 +89,6 @@ 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="邮箱已注册")
+3
View File
@@ -274,6 +274,9 @@ async def remove_member(
member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
target_user = await user_crud.get_by_id(db, member.user_id)
if target_user and _role_value(target_user) == "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统管理员不可从项目中移除")
await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member)
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
removed = await member_crud.remove_member(db, member)
+1 -5
View File
@@ -54,21 +54,17 @@ async def update_user(
if user_crud.is_protected_admin_user(db_user):
if user_in.email is not None and user_in.email != db_user.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员邮箱不允许修改")
if user_in.role is not None and user_in.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员角色不允许修改")
requested_status = user_in.status
if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
if requested_status is not None and requested_status != "ACTIVE":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用")
if db_user.role.value == "ADMIN":
requested_role = user_in.role
requested_status = user_in.status
if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
will_leave_admin = requested_role is not None and requested_role != "ADMIN"
will_disable = requested_status is not None and requested_status != "ACTIVE"
if (will_leave_admin or will_disable) and db_user.status.value == "ACTIVE":
if will_disable and db_user.status.value == "ACTIVE":
active_admins = await user_crud.count_active_admins(db)
if active_admins <= 1:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
+8 -5
View File
@@ -48,7 +48,7 @@ async def role_has_api_permission(
check_prerequisites: bool = True,
) -> bool:
"""检查角色是否有权访问特定接口"""
if role == "ADMIN":
if role == "ADMIN" or role == "PM":
return True
permissions = await _get_project_permission_overrides(db, study_id)
@@ -78,7 +78,7 @@ async def get_missing_prerequisites(
endpoint_key: str,
) -> list[str]:
"""获取缺失的前置权限列表"""
if role == "ADMIN":
if role == "ADMIN" or role == "PM":
return []
missing = []
@@ -111,8 +111,11 @@ async def get_api_endpoint_permissions(
for role in roles:
matrix[role] = {}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
if role == "PM":
matrix[role][endpoint_key] = {"allowed": True}
else:
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
for role, endpoints in overrides.items():
if role not in matrix:
@@ -136,7 +139,7 @@ async def replace_api_endpoint_permissions(
)
for role, endpoints in payload.items():
if role == "ADMIN":
if role in ("ADMIN", "PM"):
continue
for endpoint_key, allowed in endpoints.items():
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
+1 -3
View File
@@ -42,7 +42,7 @@ async def create_user(
email=user_in.email,
password_hash=hash_password(user_in.password),
full_name=user_in.full_name,
role=UserRole(user_in.role),
role=UserRole.PV,
clinical_department=user_in.clinical_department,
status=status_value,
)
@@ -58,8 +58,6 @@ async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) ->
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
update_data = {}
if user_in.role is not None:
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:
+4
View File
@@ -67,6 +67,10 @@ class User(Base):
def is_active(self) -> bool: # compatibility helper for existing checks
return self.status == UserStatus.ACTIVE
@property
def is_admin(self) -> bool:
return self.role == UserRole.ADMIN
@property
def username(self) -> str: # backward compatibility for existing UI copy
return self.email
+1 -6
View File
@@ -6,7 +6,6 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
RegisterRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
@@ -16,7 +15,6 @@ class UserDisplay(BaseModel):
id: uuid.UUID
email: EmailStr
full_name: str
role: UserRole
avatar_url: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@@ -39,7 +37,6 @@ class UserRegisterRequest(_PasswordValidator):
password: str = Field(min_length=8, max_length=72)
email: EmailStr
full_name: str = Field(min_length=1)
role: RegisterRole
clinical_department: str = Field(min_length=1)
@@ -47,7 +44,6 @@ class UserCreate(_PasswordValidator):
password: str = Field(min_length=8, max_length=72)
email: EmailStr
full_name: str = Field(min_length=1)
role: UserRole
clinical_department: str = Field(min_length=1)
status: UserStatus = "ACTIVE"
@@ -57,10 +53,10 @@ class UserRead(BaseModel):
email: EmailStr
username: str
full_name: str
role: UserRole
clinical_department: str
status: UserStatus
is_active: bool
is_admin: bool = False
created_at: datetime
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
@@ -72,7 +68,6 @@ class UserRead(BaseModel):
class UserUpdate(_PasswordValidator):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
role: Optional[UserRole] = None
clinical_department: Optional[str] = None
status: Optional[UserStatus] = None
password: Optional[str] = None