diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index ba1eed2a..fd7c6e52 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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="邮箱已注册") diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 8565910e..04552a1b 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -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) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 1c120a9f..c6858f76 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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="至少保留一个管理员账号") diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py index 752103be..27cbfad8 100644 --- a/backend/app/core/project_permissions.py +++ b/backend/app/core/project_permissions.py @@ -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: diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 3b7c4667..6e9e7588 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -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: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0eea9781..a165ba0c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index c2638d9f..5d39092e 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 2934bfe8..b3f416f2 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -9,14 +9,13 @@ export const createUser = (payload: { email: string; password: string; full_name: string; - role: string; clinical_department: string; }) => apiPost("/api/v1/users/", payload); export const updateUser = ( userId: string, - payload: Partial<{ role: string; status: string; password: string; full_name: string; clinical_department: string; is_active: boolean }> + payload: Partial<{ status: string; password: string; full_name: string; clinical_department: string; is_active: boolean }> ) => apiPatch(`/api/v1/users/${userId}`, payload); diff --git a/frontend/src/audit/index.ts b/frontend/src/audit/index.ts index 4df8f946..5eb87957 100644 --- a/frontend/src/audit/index.ts +++ b/frontend/src/audit/index.ts @@ -185,7 +185,10 @@ const compactObjectValue = (value: Record): string => { if (entries.length === 0) return "未填写"; const rendered = entries .slice(0, 3) - .map(([k, v]) => `${toReadableFieldLabel(k)}=${String(v)}`) + .map(([k, v]) => { + const valStr = typeof v === "object" ? JSON.stringify(v) : String(v); + return `${toReadableFieldLabel(k)}=${valStr}`; + }) .join(","); return entries.length > 3 ? `${rendered} 等${entries.length}项` : rendered; }; diff --git a/frontend/src/components/FaqCategoryPanel.vue b/frontend/src/components/FaqCategoryPanel.vue index 23884d2e..40fe50dc 100644 --- a/frontend/src/components/FaqCategoryPanel.vue +++ b/frontend/src/components/FaqCategoryPanel.vue @@ -51,7 +51,7 @@ const showForm = ref(false); const editing = ref(null); const { can } = usePermission(); -const isAdmin = computed(() => auth.user?.role === "ADMIN"); +const isAdmin = computed(() => !!auth.user?.is_admin); const canMaintain = computed(() => can("faq.edit")); const canDelete = computed(() => isAdmin.value); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 5e30ffb6..f11390b1 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -232,7 +232,7 @@ const auth = useAuthStore(); const study = useStudyStore(); const router = useRouter(); const route = useRoute(); -const isAdmin = computed(() => auth.user?.role === "ADMIN"); +const isAdmin = computed(() => !!auth.user?.is_admin); const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1"); const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); const canAccessProjectPath = (path: string) => diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 34e3b870..0b245797 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -36,7 +36,7 @@ export const useAuthStore = defineStore("auth", () => { const me = await fetchMeAction(); const studyStore = useStudyStore(); const userKey = me?.email || email; - await studyStore.restoreStudyForUser(userKey, { preferActive: me?.role === "ADMIN" }); + await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin }); await studyStore.loadCurrentStudyPermissions().catch(() => {}); forceLogin.value = false; } finally { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 04229bd7..14e64758 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -41,9 +41,9 @@ export interface UserInfo { username?: string; full_name: string; clinical_department: string; - role: UserRole; status: UserStatus; is_active?: boolean; + is_admin?: boolean; created_at?: string; approved_at?: string | null; approved_by?: string | null; @@ -56,7 +56,6 @@ export interface RegisterRequest { email: string; password: string; full_name: string; - role: Exclude; clinical_department: string; } diff --git a/frontend/src/utils/roles.test.ts b/frontend/src/utils/roles.test.ts index bc5c2567..d460a77c 100644 --- a/frontend/src/utils/roles.test.ts +++ b/frontend/src/utils/roles.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; -import { getProjectRole, getSystemRole, isSystemAdmin } from "./roles"; +import { getProjectRole, isSystemAdmin } from "./roles"; describe("role helpers", () => { it("keeps system admin separate from project admin", () => { - expect(isSystemAdmin({ role: "ADMIN" } as any)).toBe(true); - expect(getSystemRole({ role: "ADMIN" } as any)).toBe("ADMIN"); + expect(isSystemAdmin({ is_admin: true } as any)).toBe(true); + expect(isSystemAdmin({ is_admin: false } as any)).toBe(false); + expect(isSystemAdmin(null)).toBe(false); expect(getProjectRole({ role_in_study: null } as any, null)).toBeNull(); }); diff --git a/frontend/src/utils/roles.ts b/frontend/src/utils/roles.ts index 30e8920d..95e5cdee 100644 --- a/frontend/src/utils/roles.ts +++ b/frontend/src/utils/roles.ts @@ -1,8 +1,6 @@ import type { Study, UserInfo } from "../types/api"; -export const getSystemRole = (user?: Pick | null) => user?.role || null; - -export const isSystemAdmin = (user?: Pick | null) => getSystemRole(user) === "ADMIN"; +export const isSystemAdmin = (user?: Pick | null) => !!user?.is_admin; export const getProjectRole = (study?: Pick | null, currentStudyRole?: string | null) => currentStudyRole || study?.role_in_study || null; diff --git a/frontend/src/views/FaqDetail.vue b/frontend/src/views/FaqDetail.vue index 1af87171..9f61dbef 100644 --- a/frontend/src/views/FaqDetail.vue +++ b/frontend/src/views/FaqDetail.vue @@ -138,7 +138,7 @@ const canReply = computed(() => { const canDeleteReply = (reply: any) => { if (reply.is_deleted) return false; - if (auth.user?.role === "ADMIN") return true; + if (auth.user?.is_admin) return true; if (reply.created_by === auth.user?.id) return true; return item.value?.study_id ? can("faq.edit") : false; }; @@ -150,7 +150,7 @@ const canSelectBest = computed(() => { const canEditQuestion = computed(() => { if (!item.value) return false; - if (auth.user?.role === "ADMIN") return true; + if (auth.user?.is_admin) return true; if (item.value.created_by === auth.user?.id) return true; return can("faq.edit"); }); @@ -158,7 +158,7 @@ const canEditQuestion = computed(() => { const canConfirmResolved = computed(() => { if (!item.value) return false; if (item.value.status === "RESOLVED") return false; - if (auth.user?.role === "ADMIN") return true; + if (auth.user?.is_admin) return true; if (item.value.created_by === auth.user?.id) return true; return can("faq.edit"); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 1fa503b7..4b5cc8c7 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -240,7 +240,7 @@ const onSubmit = async () => { await tryStoreBrowserCredential(form.email, form.password); const studyStore = useStudyStore(); if (!studyStore.currentStudy) { - if (auth.user?.role === "ADMIN") { + if (auth.user?.is_admin) { await studyStore.ensureDefaultActiveStudy(); } else { await studyStore.ensureDefaultStudy(); @@ -248,7 +248,7 @@ const onSubmit = async () => { } if (studyStore.currentStudy) { router.push("/project/overview"); - } else if (auth.user?.role === "ADMIN") { + } else if (auth.user?.is_admin) { router.push("/admin/users"); } else { router.push("/workbench"); diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue index 8e8d3ebc..d3a31c71 100644 --- a/frontend/src/views/Register.vue +++ b/frontend/src/views/Register.vue @@ -337,7 +337,6 @@ import type { RegisterRequest } from "../types/api"; import { TEXT, requiredMessage } from "../locales"; import { privacyPolicySections, serviceTermsSections } from "../content/authProtocol"; -const defaultRole: RegisterRequest["role"] = "CRA"; type ProtocolType = "terms" | "privacy"; // 步骤控制 @@ -509,7 +508,6 @@ const handleSubmit = async () => { email: form.email, password: form.password, full_name: form.full_name, - role: defaultRole, clinical_department: form.clinical_department, }); registrationSubmitted.value = true; diff --git a/frontend/src/views/admin/AdminUserApproval.vue b/frontend/src/views/admin/AdminUserApproval.vue index 7a87ade9..97a6e2a1 100644 --- a/frontend/src/views/admin/AdminUserApproval.vue +++ b/frontend/src/views/admin/AdminUserApproval.vue @@ -1,58 +1,91 @@