完善项目级权限与项目管理入口

- 新增项目成员候选用户接口,避免 PM 读取全局用户列表
- 按项目角色和权限矩阵控制项目管理、成员、中心与审计入口
- 合并侧边栏管理后台入口,统一从项目管理进入成员等模块
- 加强成员管理安全约束,禁止 PM 修改自己、系统管理员或更高权限角色
- 修复多项目角色缓存串用问题,并补充前后端回归测试
This commit is contained in:
Cheng Zhou
2026-05-13 14:16:15 +08:00
parent ab1975d3c6
commit a17f4cc522
21 changed files with 452 additions and 134 deletions
+82 -2
View File
@@ -10,10 +10,20 @@ from app.crud import member as member_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.crud import user as user_crud from app.crud import user as user_crud
from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate
from app.schemas.user import UserDisplay from app.schemas.user import UserResponse
router = APIRouter() router = APIRouter()
PROJECT_ROLE_RANK = {
"ADMIN": 100,
"PM": 80,
"QA": 60,
"PV": 50,
"MEDICAL_REVIEW": 50,
"CRA": 40,
"IMP": 40,
}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
@@ -22,6 +32,44 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role)
def _role_rank(role: str | None) -> int:
return PROJECT_ROLE_RANK.get(role or "", 0)
async def _ensure_member_mutation_allowed(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
*,
target_member=None,
target_role: str | None = None,
) -> None:
if _role_value(current_user) == "ADMIN":
return
actor_member = await member_crud.get_member(db, study_id, current_user.id)
actor_role = actor_member.role_in_study if actor_member else None
actor_rank = _role_rank(actor_role)
if target_member and target_member.user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改自己的项目成员权限")
if target_member:
target_user = await user_crud.get_by_id(db, target_member.user_id)
if _role_value(target_user) == "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限")
if target_member and _role_rank(target_member.role_in_study) > actor_rank:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改权限高于自己的项目成员")
if target_role and _role_rank(target_role) > actor_rank:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能授予高于自己的项目角色")
@router.post( @router.post(
"/", "/",
response_model=StudyMemberRead, response_model=StudyMemberRead,
@@ -35,8 +83,16 @@ async def add_member(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> StudyMemberRead: ) -> StudyMemberRead:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
await _ensure_member_mutation_allowed(db, study_id, current_user, target_role=member_in.role_in_study)
existing = await member_crud.get_member(db, study_id, member_in.user_id) existing = await member_crud.get_member(db, study_id, member_in.user_id)
if existing: if existing:
await _ensure_member_mutation_allowed(
db,
study_id,
current_user,
target_member=existing,
target_role=member_in.role_in_study,
)
if not existing.is_active: if not existing.is_active:
updated = await member_crud.update_member( updated = await member_crud.update_member(
db, db,
@@ -106,12 +162,28 @@ async def list_members(
role_in_study=m.role_in_study, role_in_study=m.role_in_study,
is_active=m.is_active, is_active=m.is_active,
added_at=m.added_at, added_at=m.added_at,
user=UserDisplay.model_validate(user) if user else None, user=UserResponse.model_validate(user) if user else None,
) )
) )
return result return result
@router.get(
"/candidates",
response_model=list[UserResponse],
dependencies=[Depends(require_study_permission("project_members", "write"))],
)
async def list_member_candidates(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 500,
db: AsyncSession = Depends(get_db_session),
) -> list[UserResponse]:
await _ensure_study_exists(db, study_id)
users = await user_crud.list_active_member_candidates_for_study(db, study_id, skip=skip, limit=limit)
return [UserResponse.model_validate(user) for user in users]
@router.patch( @router.patch(
"/{member_id}", "/{member_id}",
response_model=StudyMemberRead, response_model=StudyMemberRead,
@@ -128,6 +200,13 @@ async def update_member(
member = await member_crud.get_member_by_id(db, member_id) member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id: if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
await _ensure_member_mutation_allowed(
db,
study_id,
current_user,
target_member=member,
target_role=member_in.role_in_study,
)
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
updated = await member_crud.update_member(db, member, member_in) updated = await member_crud.update_member(db, member, member_in)
after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active} after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active}
@@ -159,6 +238,7 @@ async def remove_member(
member = await member_crud.get_member_by_id(db, member_id) member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id: if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 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} before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
removed = await member_crud.remove_member(db, member) removed = await member_crud.remove_member(db, member)
await audit_crud.log_action( await audit_crud.log_action(
+2 -2
View File
@@ -6,7 +6,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_permission
from app.core.project_permissions import PROJECT_PERMISSION_MODULES, get_project_role_permissions, replace_project_role_permissions from app.core.project_permissions import PROJECT_PERMISSION_MODULES, get_project_role_permissions, replace_project_role_permissions
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import study as study_crud from app.crud import study as study_crud
@@ -33,7 +33,7 @@ async def get_permissions(
return ProjectRolePermissionsRead(modules=modules, roles=roles) return ProjectRolePermissionsRead(modules=modules, roles=roles)
@router.put("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_roles(["ADMIN"]))]) @router.put("/", response_model=ProjectRolePermissionsRead, dependencies=[Depends(require_study_permission("project_members", "write"))])
async def update_permissions( async def update_permissions(
study_id: uuid.UUID, study_id: uuid.UUID,
payload: ProjectRolePermissionsUpdate, payload: ProjectRolePermissionsUpdate,
+23
View File
@@ -85,6 +85,29 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque
return result.scalars().all() return result.scalars().all()
async def list_active_member_candidates_for_study(
db: AsyncSession,
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
) -> Sequence[User]:
existing_member = (
select(StudyMember.id)
.where(StudyMember.study_id == study_id, StudyMember.user_id == User.id)
.exists()
)
stmt = (
select(User)
.where(User.status == UserStatus.ACTIVE)
.where(~existing_member)
.order_by(User.full_name.asc(), User.email.asc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
async def count_active_admins(db: AsyncSession) -> int: async def count_active_admins(db: AsyncSession) -> int:
result = await db.execute( result = await db.execute(
select(func.count()) select(func.count())
+2 -2
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.schemas.user import UserDisplay from app.schemas.user import UserResponse
StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"] StudyRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
@@ -31,4 +31,4 @@ class StudyMemberRead(BaseModel):
class StudyMemberReadWithUser(StudyMemberRead): class StudyMemberReadWithUser(StudyMemberRead):
user: Optional[UserDisplay] = None user: Optional[UserResponse] = None
@@ -19,6 +19,13 @@ def test_project_permission_routes_are_registered():
assert 'prefix="/studies/{study_id}/permissions"' in source assert 'prefix="/studies/{study_id}/permissions"' in source
def test_project_permission_updates_use_project_member_write_permission():
source = (ROOT / "app/api/v1/project_permissions.py").read_text(encoding="utf-8")
assert 'require_study_permission("project_members", "write")' in source
assert 'require_roles(["ADMIN"])' not in source
def test_project_member_writes_use_saved_permission_matrix(): def test_project_member_writes_use_saved_permission_matrix():
source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8") source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8")
@@ -26,6 +33,31 @@ def test_project_member_writes_use_saved_permission_matrix():
assert 'require_study_roles(["ADMIN", "PM"])' not in source assert 'require_study_roles(["ADMIN", "PM"])' not in source
def test_project_member_candidates_use_project_write_permission():
source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8")
user_crud_source = (ROOT / "app/crud/user.py").read_text(encoding="utf-8")
assert '"/candidates"' in source
assert 'response_model=list[UserResponse]' in source
assert 'dependencies=[Depends(require_study_permission("project_members", "write"))]' in source
assert "list_active_member_candidates_for_study" in source
assert "User.status == UserStatus.ACTIVE" in user_crud_source
assert ".where(~existing_member)" in user_crud_source
def test_project_member_mutation_blocks_self_and_higher_role_changes():
source = (ROOT / "app/api/v1/members.py").read_text(encoding="utf-8")
assert "PROJECT_ROLE_RANK" in source
assert 'if target_member and target_member.user_id == current_user.id:' in source
assert "不能修改自己的项目成员权限" in source
assert "不能修改系统管理员账号的项目权限" in source
assert 'if _role_value(target_user) == "ADMIN":' in source
assert "不能修改权限高于自己的项目成员" in source
assert "不能授予高于自己的项目角色" in source
assert "_ensure_member_mutation_allowed(" in source
def test_business_modules_use_saved_permission_matrix(): def test_business_modules_use_saved_permission_matrix():
expected = { expected = {
"overview.py": [ "overview.py": [
+4 -1
View File
@@ -1,9 +1,12 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { StudyMember } from "../types/api"; import type { StudyMember, UserInfo } from "../types/api";
export const listMembers = (studyId: string, params?: Record<string, any>) => export const listMembers = (studyId: string, params?: Record<string, any>) =>
apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, { params }); apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, { params });
export const listMemberCandidates = (studyId: string, params?: Record<string, any>) =>
apiGet<UserInfo[]>(`/api/v1/studies/${studyId}/members/candidates`, { params, suppressErrorMessage: true });
export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) => export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) =>
apiPost<StudyMember>(`/api/v1/studies/${studyId}/members/`, payload); apiPost<StudyMember>(`/api/v1/studies/${studyId}/members/`, payload);
+2 -25
View File
@@ -17,11 +17,11 @@
<span>{{ TEXT.menu.workbench }}</span> <span>{{ TEXT.menu.workbench }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item-group v-if="isAdmin" class="menu-group"> <el-menu-item-group v-if="auth.user" class="menu-group">
<template #title> <template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span> <span class="menu-divider">{{ TEXT.menu.admin }}</span>
</template> </template>
<el-menu-item index="/admin/users"> <el-menu-item v-if="isAdmin" index="/admin/users">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>{{ TEXT.menu.accountManagement }}</span> <span>{{ TEXT.menu.accountManagement }}</span>
</el-menu-item> </el-menu-item>
@@ -35,24 +35,6 @@
</el-menu-item> </el-menu-item>
</el-menu-item-group> </el-menu-item-group>
<el-menu-item-group v-if="canAccessPmAdminBackend" class="menu-group">
<template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
</template>
<el-menu-item v-if="canAccessPmAdminModule('project_members')" :index="`/admin/projects/${study.currentStudy?.id}/members`">
<el-icon><User /></el-icon>
<span>{{ TEXT.modules.adminProjectMembers.memberLabel }}</span>
</el-menu-item>
<el-menu-item v-if="canAccessPmAdminModule('sites')" :index="`/admin/projects/${study.currentStudy?.id}/sites`">
<el-icon><Suitcase /></el-icon>
<span>{{ TEXT.modules.adminSites.title }}</span>
</el-menu-item>
<el-menu-item v-if="canAccessPmAdminModule('audit_export')" index="/admin/audit-logs">
<el-icon><Document /></el-icon>
<span>{{ TEXT.menu.auditLogs }}</span>
</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group"> <el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group">
<template #title> <template #title>
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span> <span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
@@ -244,11 +226,6 @@ const route = useRoute();
const isAdmin = computed(() => auth.user?.role === "ADMIN"); const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1"); const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessPmAdminModule = (module: string) =>
!isAdmin.value && projectRole.value === "PM" && !!study.currentPermissions?.roles?.PM?.[module]?.read;
const canAccessPmAdminBackend = computed(() =>
!!study.currentStudy && ["project_members", "sites", "audit_export"].some((module) => canAccessPmAdminModule(module))
);
const canAccessProjectPath = (path: string) => const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value); hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path)); const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
+7 -7
View File
@@ -9,19 +9,19 @@ describe("admin project route permissions", () => {
const source = readRouter(); const source = readRouter();
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "read" }'); expect(source).toContain('adminProjectPermission: { module: "project_members", action: "read" }');
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "write" }');
expect(source).toContain('adminProjectPermission: { module: "sites", action: "read" }'); expect(source).toContain('adminProjectPermission: { module: "sites", action: "read" }');
expect(source).toContain('adminProjectPermission: { module: "audit_export", action: "read" }'); expect(source).toContain('adminProjectPermission: { module: "audit_export", action: "read" }');
expect(source).toContain("meta: { title: TEXT.menu.projectManagement }");
expect(source).toContain('if (role !== "PM") return false;'); expect(source).toContain('if (role !== "PM") return false;');
expect(source).toContain("roles?.PM?.[permission.module]?.[permission.action]"); expect(source).toContain("roles?.[role]?.[permission.module]?.[permission.action]");
}); });
it("sends authorized project PMs to the management backend after login", () => { it("keeps login landing independent from PM management backend access", () => {
const source = readRouter(); const source = readRouter();
expect(source).toContain("const findPmAdminLandingPath = async"); expect(source).not.toContain("findPmAdminLandingPath");
expect(source).toContain("pmAdminLandingModules.find"); expect(source).not.toContain("pmAdminLandingModules");
expect(source).toContain("const pmAdminLanding = isAdmin ? null : await findPmAdminLandingPath()"); expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/workbench",');
expect(source).toContain("? pmAdminLanding");
expect(source).toContain("`/admin/projects/${studyId}/members`");
}); });
}); });
+3 -32
View File
@@ -427,7 +427,7 @@ const routes: RouteRecordRaw[] = [
path: "projects", path: "projects",
name: "AdminProjects", name: "AdminProjects",
component: AdminProjects, component: AdminProjects,
meta: { title: TEXT.menu.projectManagement, requiresAdmin: true }, meta: { title: TEXT.menu.projectManagement },
}, },
{ {
path: "projects/:projectId", path: "projects/:projectId",
@@ -445,7 +445,7 @@ const routes: RouteRecordRaw[] = [
path: "projects/:projectId/permissions", path: "projects/:projectId/permissions",
name: "AdminProjectPermissions", name: "AdminProjectPermissions",
component: ProjectPermissions, component: ProjectPermissions,
meta: { title: "权限管理", requiresAdmin: true }, meta: { title: "权限管理", adminProjectPermission: { module: "project_members", action: "write" } },
}, },
{ {
path: "projects/:projectId/sites", path: "projects/:projectId/sites",
@@ -472,25 +472,6 @@ const router = createRouter({
routes, routes,
}); });
const pmAdminLandingModules: Array<{ module: string; path: (studyId: string) => string }> = [
{ module: "project_members", path: (studyId) => `/admin/projects/${studyId}/members` },
{ module: "sites", path: (studyId) => `/admin/projects/${studyId}/sites` },
{ module: "audit_export", path: () => "/admin/audit-logs" },
];
const findPmAdminLandingPath = async () => {
const studyStore = useStudyStore();
const studyId = studyStore.currentStudy?.id;
if (!studyId) return null;
if (!studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions();
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
if (role !== "PM") return null;
const matched = pmAdminLandingModules.find((item) => !!studyStore.currentPermissions?.roles?.PM?.[item.module]?.read);
return matched ? matched.path(studyId) : null;
};
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => { const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
if (isAdmin) return true; if (isAdmin) return true;
const studyStore = useStudyStore(); const studyStore = useStudyStore();
@@ -511,7 +492,7 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
if (role !== "PM") return false; if (role !== "PM") return false;
return !!studyStore.currentPermissions?.roles?.PM?.[permission.module]?.[permission.action]; return !!studyStore.currentPermissions?.roles?.[role]?.[permission.module]?.[permission.action];
}; };
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
@@ -549,23 +530,13 @@ router.beforeEach(async (to, _from, next) => {
next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" }); next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" });
return; return;
} }
if (!isAdmin && token && (to.path === "/" || to.path === "/workbench")) {
const pmAdminLanding = await findPmAdminLandingPath().catch(() => null);
if (pmAdminLanding) {
next({ path: pmAdminLanding });
return;
}
}
if ((to.path === "/login" || to.path === "/register") && token) { if ((to.path === "/login" || to.path === "/register") && token) {
if (!auth.forceLogin) { if (!auth.forceLogin) {
const pmAdminLanding = isAdmin ? null : await findPmAdminLandingPath().catch(() => null);
next({ next({
path: isAdmin path: isAdmin
? studyStore.currentStudy ? studyStore.currentStudy
? "/project/overview" ? "/project/overview"
: "/admin/users" : "/admin/users"
: pmAdminLanding
? pmAdminLanding
: studyStore.currentStudy : studyStore.currentStudy
? "/project/overview" ? "/project/overview"
: "/workbench", : "/workbench",
+50
View File
@@ -83,4 +83,54 @@ describe("study store startup rehydration", () => {
expect(study.currentStudyRole).toBeNull(); expect(study.currentStudyRole).toBeNull();
expect(window.localStorage.getItem("ctms_current_study_role")).toBeNull(); expect(window.localStorage.getItem("ctms_current_study_role")).toBeNull();
}); });
it("derives the current role from the persisted study instead of a stale global role", async () => {
window.localStorage.setItem(
"ctms_current_study",
JSON.stringify({
id: "study-b",
code: "B",
name: "项目 B",
status: "ACTIVE",
role_in_study: "CRA",
})
);
window.localStorage.setItem("ctms_current_study_role", "PM");
const { useStudyStore } = await import("./study");
const study = useStudyStore();
study.loadCurrentStudy();
expect(study.currentStudy?.id).toBe("study-b");
expect(study.currentStudyRole).toBe("CRA");
expect(window.localStorage.getItem("ctms_current_study_role")).toBeNull();
});
it("updates role and clears permissions when switching between projects with different roles", async () => {
const { useStudyStore } = await import("./study");
const study = useStudyStore();
study.setCurrentStudy({
id: "study-a",
code: "A",
name: "项目 A",
status: "ACTIVE",
role_in_study: "PM",
} as any);
study.currentPermissions = { roles: { PM: { project_members: { read: true, write: true } } } };
study.setCurrentStudy({
id: "study-b",
code: "B",
name: "项目 B",
status: "ACTIVE",
role_in_study: "CRA",
} as any);
expect(study.currentStudy?.id).toBe("study-b");
expect(study.currentStudyRole).toBe("CRA");
expect(study.currentPermissions).toBeNull();
expect(JSON.parse(window.localStorage.getItem("ctms_current_study") || "{}").role_in_study).toBe("CRA");
expect(window.localStorage.getItem("ctms_current_study_role")).toBeNull();
});
}); });
+7 -10
View File
@@ -44,13 +44,10 @@ export const useStudyStore = defineStore("study", () => {
currentStudy.value = study; currentStudy.value = study;
currentSite.value = null; // 切换项目时清空中心 currentSite.value = null; // 切换项目时清空中心
localStorage.removeItem(SITE_KEY); localStorage.removeItem(SITE_KEY);
currentStudy.value = study;
const role = (study as any).role_in_study || null; const role = (study as any).role_in_study || null;
currentStudyRole.value = role; currentStudyRole.value = role;
localStorage.setItem(STUDY_KEY, JSON.stringify(study)); localStorage.setItem(STUDY_KEY, JSON.stringify(study));
if (role) { localStorage.removeItem(STUDY_ROLE_KEY);
localStorage.setItem(STUDY_ROLE_KEY, role);
}
currentPermissions.value = null; currentPermissions.value = null;
const loginEmail = localStorage.getItem("ctms_last_login_email") || ""; const loginEmail = localStorage.getItem("ctms_last_login_email") || "";
if (loginEmail) { if (loginEmail) {
@@ -67,8 +64,8 @@ export const useStudyStore = defineStore("study", () => {
currentStudy.value = null; currentStudy.value = null;
} }
} }
const savedRole = localStorage.getItem(STUDY_ROLE_KEY); currentStudyRole.value = (currentStudy.value as any)?.role_in_study || null;
currentStudyRole.value = savedRole || null; localStorage.removeItem(STUDY_ROLE_KEY);
const savedSite = localStorage.getItem(SITE_KEY); const savedSite = localStorage.getItem(SITE_KEY);
if (savedSite) { if (savedSite) {
@@ -200,11 +197,11 @@ export const useStudyStore = defineStore("study", () => {
const setCurrentStudyRole = (role: string | null) => { const setCurrentStudyRole = (role: string | null) => {
currentStudyRole.value = role; currentStudyRole.value = role;
if (role) { if (currentStudy.value) {
localStorage.setItem(STUDY_ROLE_KEY, role); currentStudy.value = { ...currentStudy.value, role_in_study: role };
} else { localStorage.setItem(STUDY_KEY, JSON.stringify(currentStudy.value));
localStorage.removeItem(STUDY_ROLE_KEY);
} }
localStorage.removeItem(STUDY_ROLE_KEY);
}; };
const setCurrentSite = (site: any | null) => { const setCurrentSite = (site: any | null) => {
+1
View File
@@ -110,6 +110,7 @@ export interface StudyMember {
role_in_study: string; role_in_study: string;
is_active: boolean; is_active: boolean;
added_at: string; added_at: string;
user?: Partial<UserInfo> | null;
} }
export interface Site { export interface Site {
+2 -1
View File
@@ -13,7 +13,8 @@ describe("audit logs access", () => {
expect(source).toContain("getProjectRole"); expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole"); expect(source).toContain("study.currentStudyRole");
expect(source).toContain('projectRole.value === "PM"'); expect(source).toContain('projectRole.value === "PM"');
expect(source).toContain("roles?.PM?.audit_export?.read"); expect(source).toContain("roles?.[projectRole.value]?.audit_export?.read");
expect(source).not.toContain("roles?.PM?.audit_export?.read");
expect(source).not.toContain('const role = auth.user?.role'); expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"'); expect(source).not.toContain('role !== "ADMIN"');
}); });
+2 -2
View File
@@ -216,11 +216,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => { const canProjectExport = computed(() => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.PM?.audit_export?.read; return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
}); });
const canAccessAuditLogs = computed(() => { const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.PM?.audit_export?.read; return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
}); });
const ensureAccess = () => { const ensureAccess = () => {
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readProjectMembers = () => readFileSync(resolve(__dirname, "./ProjectMembers.vue"), "utf8");
describe("ProjectMembers user directory access", () => {
it("does not load the admin-only global user directory", () => {
const source = readProjectMembers();
expect(source).not.toContain("fetchUsers");
expect(source).not.toContain("../../api/users");
expect(source).toContain('const canManageMembers = computed(() => permission.can("project.members.manage"));');
expect(source).toContain("if (!canManageMembers.value");
expect(source).toContain("return;");
expect(source).toContain('v-if="canManageMembers"');
expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })");
});
it("uses the member API embedded user data when the global directory is unavailable", () => {
const source = readProjectMembers();
expect(source).toContain("users.value = members.value");
expect(source).toContain(".map((member) => member.user)");
expect(source).toContain("users.value.find((u) => u.id === m.user_id) || m.user");
});
it("prevents project managers from editing themselves or assigning higher roles", () => {
const source = readProjectMembers();
expect(source).toContain("const roleRank: Record<string, number>");
expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
expect(source).toContain('if (row.user?.role === "ADMIN") return false;');
expect(source).toContain("const canAssignRole = (role: string)");
expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"');
expect(source).toContain(':disabled="!canEditMember(scope.row)');
});
});
+77 -27
View File
@@ -3,7 +3,7 @@
<div class="main-content-flat unified-shell"> <div class="main-content-flat unified-shell">
<div class="unified-action-bar actions-only-bar"> <div class="unified-action-bar actions-only-bar">
<div class="filter-spacer"></div> <div class="filter-spacer"></div>
<el-button type="primary" @click="openAdd"> <el-button v-if="canManageMembers" type="primary" @click="openAdd">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }} {{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
</el-button> </el-button>
</div> </div>
@@ -39,23 +39,23 @@
size="small" size="small"
style="width: 120px" style="width: 120px"
@change="(val: string) => updateRole(scope.row.id, val)" @change="(val: string) => updateRole(scope.row.id, val)"
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
> >
<el-option :label="TEXT.enums.userRole.PM" value="PM" /> <el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" />
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" /> <el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" />
<el-option :label="TEXT.enums.userRole.PV" value="PV" /> <el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" />
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" /> <el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" />
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" /> <el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" />
<el-option :label="TEXT.enums.userRole.QA" value="QA" /> <el-option :label="TEXT.enums.userRole.QA" value="QA" :disabled="!canAssignRole('QA')" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" /> <el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
</el-select> </el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span> <span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button link type="danger" size="small" :disabled="!canEditMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button <el-button
link link
:type="scope.row.is_active ? 'danger' : 'primary'" :type="scope.row.is_active ? 'danger' : 'primary'"
size="small" size="small"
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!canEditMember(scope.row) || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleActive(scope.row)" @click="toggleActive(scope.row)"
> >
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }} {{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
@@ -81,13 +81,13 @@
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study"> <el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study">
<el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder"> <el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder">
<el-option :label="TEXT.enums.userRole.PM" value="PM" /> <el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" />
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" /> <el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" />
<el-option :label="TEXT.enums.userRole.PV" value="PV" /> <el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" />
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" /> <el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" />
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" /> <el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" />
<el-option :label="TEXT.enums.userRole.QA" value="QA" /> <el-option :label="TEXT.enums.userRole.QA" value="QA" :disabled="!canAssignRole('QA')" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" /> <el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -103,18 +103,19 @@
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { fetchUsers } from "../../api/users"; import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "../../api/members";
import { addMember, listMembers, removeMember, updateMember } from "../../api/members";
import { fetchStudyDetail } from "../../api/studies"; import { fetchStudyDetail } from "../../api/studies";
import type { Study, StudyMember, UserInfo } from "../../types/api"; import type { Study, StudyMember, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard"; import { evaluateAction } from "../../guards/actionGuard";
import { usePermission } from "../../utils/permission";
import { displayDateTime, displayEnum } from "../../utils/display"; import { displayDateTime, displayEnum } from "../../utils/display";
import { TEXT, requiredMessage } from "../../locales"; import { TEXT, requiredMessage } from "../../locales";
const route = useRoute(); const route = useRoute();
const projectId = computed(() => route.params.projectId as string); const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore(); const auth = useAuthStore();
const permission = usePermission();
const project = ref<Study | null>(null); const project = ref<Study | null>(null);
const members = ref<StudyMember[]>([]); const members = ref<StudyMember[]>([]);
@@ -127,6 +128,26 @@ const newMember = reactive({
user_id: "", user_id: "",
role_in_study: "PM", role_in_study: "PM",
}); });
const canManageMembers = computed(() => permission.can("project.members.manage"));
const projectRole = computed(() => project.value?.role_in_study || "");
const roleRank: Record<string, number> = {
ADMIN: 100,
PM: 80,
QA: 60,
PV: 50,
MEDICAL_REVIEW: 50,
CRA: 40,
IMP: 40,
};
const currentRoleRank = computed(() => auth.user?.role === "ADMIN" ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
const canAssignRole = (role: string) => (auth.user?.role === "ADMIN" ? true : (roleRank[role] || 0) <= currentRoleRank.value);
const canEditMember = (row: StudyMember) => {
if (!canManageMembers.value) return false;
if (auth.user?.role === "ADMIN") return true;
if (row.user_id === auth.user?.id) return false;
if (row.user?.role === "ADMIN") return false;
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
};
const addRules = reactive<FormRules>({ const addRules = reactive<FormRules>({
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }], user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
@@ -149,6 +170,11 @@ const loadMembers = async () => {
try { try {
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true }); const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
members.value = Array.isArray(data) ? data : data.items || []; members.value = Array.isArray(data) ? data : data.items || [];
if (!canManageMembers.value) {
users.value = members.value
.map((member) => member.user)
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
}
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
} finally { } finally {
@@ -157,9 +183,13 @@ const loadMembers = async () => {
}; };
const loadUsers = async () => { const loadUsers = async () => {
if (!canManageMembers.value || !projectId.value) {
users.value = [];
return;
}
try { try {
const { data } = await fetchUsers({ limit: 500 }); const { data } = await listMemberCandidates(projectId.value, { limit: 500 });
users.value = (data as any).items || []; users.value = data || [];
} catch { } catch {
users.value = []; users.value = [];
} }
@@ -167,7 +197,7 @@ const loadUsers = async () => {
const memberRows = computed(() => const memberRows = computed(() =>
members.value.map((m) => { members.value.map((m) => {
const user = users.value.find((u) => u.id === m.user_id); const user = users.value.find((u) => u.id === m.user_id) || m.user;
const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED"; const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
return { return {
...m, ...m,
@@ -178,8 +208,9 @@ const memberRows = computed(() =>
); );
const openAdd = () => { const openAdd = () => {
if (!canManageMembers.value) return;
newMember.user_id = ""; newMember.user_id = "";
newMember.role_in_study = "PM"; newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
addVisible.value = true; addVisible.value = true;
}; };
@@ -194,13 +225,17 @@ const submitAdd = async () => {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission); ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
return; return;
} }
if (!canAssignRole(newMember.role_in_study)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
await addFormRef.value?.validate(); await addFormRef.value?.validate();
adding.value = true; adding.value = true;
try { try {
await addMember(projectId.value, newMember); await addMember(projectId.value, newMember);
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess); ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
addVisible.value = false; addVisible.value = false;
loadMembers(); await Promise.all([loadMembers(), loadUsers()]);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
} finally { } finally {
@@ -210,6 +245,12 @@ const submitAdd = async () => {
const updateRole = async (memberId: string, role: string) => { const updateRole = async (memberId: string, role: string) => {
if (!projectId.value) return; if (!projectId.value) return;
const row = members.value.find((member) => member.id === memberId);
if (!row || !canEditMember(row) || !canAssignRole(role)) {
ElMessage.warning(TEXT.common.messages.noPermission);
loadMembers();
return;
}
const decision = evaluateAction({ const decision = evaluateAction({
actorRole: auth.user?.role || null, actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage", requiredPermission: "project.members.manage",
@@ -231,6 +272,10 @@ const updateRole = async (memberId: string, role: string) => {
const toggleActive = async (row: StudyMember) => { const toggleActive = async (row: StudyMember) => {
if (!projectId.value) return; if (!projectId.value) return;
if (!canEditMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({ const decision = evaluateAction({
actorRole: auth.user?.role || null, actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage", requiredPermission: "project.members.manage",
@@ -266,6 +311,10 @@ const toggleActive = async (row: StudyMember) => {
const onDelete = async (row: StudyMember) => { const onDelete = async (row: StudyMember) => {
if (!projectId.value) return; if (!projectId.value) return;
if (!canEditMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({ const decision = evaluateAction({
actorRole: auth.user?.role || null, actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage", requiredPermission: "project.members.manage",
@@ -284,6 +333,7 @@ const onDelete = async (row: StudyMember) => {
try { try {
await removeMember(projectId.value, row.id); await removeMember(projectId.value, row.id);
members.value = members.value.filter((m) => m.id !== row.id); members.value = members.value.filter((m) => m.id !== row.id);
await loadUsers();
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess); ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
@@ -296,8 +346,8 @@ const availableUsers = computed(() => {
}); });
onMounted(async () => { onMounted(async () => {
await Promise.all([loadProject(), loadUsers()]); await Promise.all([loadProject(), loadMembers()]);
loadMembers(); loadUsers();
}); });
</script> </script>
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "utf8");
const readRouter = () => readFileSync(resolve(__dirname, "../../router/index.ts"), "utf8");
const readLayout = () => readFileSync(resolve(__dirname, "../../components/Layout.vue"), "utf8");
describe("project management access", () => {
it("shows project management to all signed-in users while keeping system operations admin-only", () => {
const projects = readProjects();
const layout = readLayout();
const router = readRouter();
expect(layout).toContain('v-if="auth.user"');
expect(layout).toContain('index="/admin/projects"');
expect(router).toContain('name: "AdminProjects"');
expect(router).toContain("meta: { title: TEXT.menu.projectManagement }");
expect(projects).toContain('v-if="isAdmin" type="primary"');
expect(projects).toContain('v-if="isAdmin && !scope.row.is_locked"');
expect(projects).toContain('v-if="isAdmin" :content="TEXT.common.actions.delete"');
});
it("keeps management backend modules under the project management page instead of sidebar shortcuts", () => {
const layout = readLayout();
expect(layout).not.toContain("canAccessPmAdminBackend");
expect(layout).not.toContain("canAccessPmAdminModule");
expect(layout).not.toContain("TEXT.modules.adminProjectMembers.memberLabel");
expect(layout).toContain('index="/admin/projects"');
});
it("uses each project permission matrix to show project management actions", () => {
const source = readProjects();
expect(source).toContain("fetchProjectRolePermissions(item.id)");
expect(source).toContain("permissionsByProject");
expect(source).toContain("const canProject = (row: Study, module: string, action: \"read\" | \"write\")");
expect(source).toContain("canProject(scope.row, 'project_members', 'read')");
expect(source).toContain("canProject(scope.row, 'project_members', 'write')");
expect(source).toContain("canProject(scope.row, 'sites', 'read')");
expect(source).toContain("canProject(scope.row, 'audit_export', 'read')");
});
});
+55 -12
View File
@@ -3,7 +3,7 @@
<div class="main-content-flat unified-shell"> <div class="main-content-flat unified-shell">
<div class="unified-action-bar actions-only-bar"> <div class="unified-action-bar actions-only-bar">
<div class="filter-spacer"></div> <div class="filter-spacer"></div>
<el-button type="primary" @click="openCreate"> <el-button v-if="isAdmin" type="primary" @click="openCreate">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }} {{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
</el-button> </el-button>
</div> </div>
@@ -11,7 +11,7 @@
<el-table :data="projects" v-loading="loading" stripe class="project-table" table-layout="fixed"> <el-table :data="projects" v-loading="loading" stripe class="project-table" table-layout="fixed">
<el-table-column prop="name" :label="TEXT.common.fields.projectName" show-overflow-tooltip> <el-table-column prop="name" :label="TEXT.common.fields.projectName" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link> <el-link type="primary" @click="goProject(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" show-overflow-tooltip> <el-table-column prop="code" :label="TEXT.common.fields.projectCode" show-overflow-tooltip>
@@ -33,19 +33,22 @@
<el-table-column :label="TEXT.common.labels.actions" width="176" align="center"> <el-table-column :label="TEXT.common.labels.actions" width="176" align="center">
<template #default="scope"> <template #default="scope">
<div class="action-row"> <div class="action-row">
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top"> <el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" /> <el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
</el-tooltip> </el-tooltip>
<el-tooltip content="权限管理" placement="top"> <el-tooltip v-if="canProject(scope.row, 'project_members', 'write')" content="权限管理" placement="top">
<el-button link type="primary" :icon="Key" class="action-btn" @click="goPermissions(scope.row)" /> <el-button link type="primary" :icon="Key" class="action-btn" @click="goPermissions(scope.row)" />
</el-tooltip> </el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top"> <el-tooltip v-if="canProject(scope.row, 'sites', 'read')" :content="TEXT.modules.adminProjects.sites" placement="top">
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" /> <el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
</el-tooltip> </el-tooltip>
<el-tooltip v-if="canProject(scope.row, 'audit_export', 'read')" :content="TEXT.menu.auditLogs" placement="top">
<el-button link type="primary" :icon="Document" class="action-btn" @click="goAuditLogs(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top"> <el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" /> <el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
</el-tooltip> </el-tooltip>
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top"> <el-tooltip v-if="isAdmin && !scope.row.is_locked" content="锁定项目" placement="top">
<el-button <el-button
link link
type="info" type="info"
@@ -54,7 +57,7 @@
@click="handleLockToggle(scope.row)" @click="handleLockToggle(scope.row)"
/> />
</el-tooltip> </el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top"> <el-tooltip v-if="isAdmin" :content="TEXT.common.actions.delete" placement="top">
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" /> <el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
</el-tooltip> </el-tooltip>
</div> </div>
@@ -68,14 +71,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key } from "@element-plus/icons-vue"; import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document } from "@element-plus/icons-vue";
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies"; import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
import type { Study } from "../../types/api"; import { fetchProjectRolePermissions } from "../../api/projectPermissions";
import type { ProjectRolePermissionsResponse, Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue"; import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
const projects = ref<Study[]>([]); const projects = ref<Study[]>([]);
@@ -84,6 +90,9 @@ const formVisible = ref(false);
const editingProject = ref<Study | null>(null); const editingProject = ref<Study | null>(null);
const router = useRouter(); const router = useRouter();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const permissionsByProject = ref<Record<string, ProjectRolePermissionsResponse>>({});
const loadProjects = async () => { const loadProjects = async () => {
loading.value = true; loading.value = true;
@@ -91,6 +100,19 @@ const loadProjects = async () => {
const { data } = await fetchStudies(); const { data } = await fetchStudies();
const items = (data as any).items || []; const items = (data as any).items || [];
projects.value = items; projects.value = items;
if (!isAdmin.value) {
const entries = await Promise.all(
items.map(async (item: Study) => {
try {
const { data: permissions } = await fetchProjectRolePermissions(item.id);
return [item.id, permissions] as const;
} catch {
return [item.id, null] as const;
}
})
);
permissionsByProject.value = Object.fromEntries(entries.filter((entry) => entry[1]));
}
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
} finally { } finally {
@@ -99,10 +121,17 @@ const loadProjects = async () => {
}; };
const openCreate = () => { const openCreate = () => {
if (!isAdmin.value) return;
editingProject.value = null; editingProject.value = null;
formVisible.value = true; formVisible.value = true;
}; };
const canProject = (row: Study, module: string, action: "read" | "write") => {
if (isAdmin.value) return true;
const role = row.role_in_study || "";
return !!permissionsByProject.value[row.id]?.roles?.[role as keyof ProjectRolePermissionsResponse["roles"]]?.[module]?.[action];
};
const goMembers = (row: Study) => { const goMembers = (row: Study) => {
router.push(`/admin/projects/${row.id}/members`); router.push(`/admin/projects/${row.id}/members`);
}; };
@@ -115,7 +144,12 @@ const goPermissions = (row: Study) => {
router.push(`/admin/projects/${row.id}/permissions`); router.push(`/admin/projects/${row.id}/permissions`);
}; };
const goAuditLogs = async (row: Study) => {
await enterStudy(row, "/admin/audit-logs");
};
const handleDelete = async (study: Study) => { const handleDelete = async (study: Study) => {
if (!isAdmin.value) return;
try { try {
// 第一次确认 // 第一次确认
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -165,6 +199,7 @@ const handleDelete = async (study: Study) => {
}; };
const handleLockToggle = async (study: Study) => { const handleLockToggle = async (study: Study) => {
if (!isAdmin.value) return;
// 锁定后不可解锁 // 锁定后不可解锁
if (study.is_locked) { if (study.is_locked) {
ElMessage.warning("项目已锁定,不可解锁"); ElMessage.warning("项目已锁定,不可解锁");
@@ -204,16 +239,24 @@ const handleLockToggle = async (study: Study) => {
} }
}; };
const enterStudy = async (row: Study) => { const enterStudy = async (row: Study, path = "/project/overview") => {
studyStore.setCurrentStudy(row); studyStore.setCurrentStudy(row);
await studyStore.loadCurrentStudyPermissions().catch(() => {}); await studyStore.loadCurrentStudyPermissions().catch(() => {});
router.push("/project/overview"); router.push(path);
}; };
const goDetail = (row: Study) => { const goDetail = (row: Study) => {
router.push(`/admin/projects/${row.id}`); router.push(`/admin/projects/${row.id}`);
}; };
const goProject = (row: Study) => {
if (isAdmin.value) {
goDetail(row);
return;
}
enterStudy(row);
};
const statusLabel = (status: string) => const statusLabel = (status: string) =>
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status; TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
+12
View File
@@ -17,3 +17,15 @@ describe("Admin site form phone field", () => {
expect(typeSource).toContain("contact_phone?: string | null;"); expect(typeSource).toContain("contact_phone?: string | null;");
}); });
}); });
describe("Admin site PM user loading", () => {
it("uses project member embedded user data instead of the global user directory", () => {
const sitesSource = readFileSync(resolve(__dirname, "./Sites.vue"), "utf8");
const formSource = readSiteForm();
expect(sitesSource).not.toContain("../../api/users");
expect(sitesSource).not.toContain("fetchUsers");
expect(sitesSource).toContain(".map((member) => member.user)");
expect(formSource).toContain("m.user?.full_name");
});
});
+1 -1
View File
@@ -86,7 +86,7 @@ const craOptions = computed(() => {
return acc; return acc;
}, {}); }, {});
return (props.members || []).map((m: any) => ({ return (props.members || []).map((m: any) => ({
label: userMap[m.user_id] || m.full_name || m.username || m.user_id, label: userMap[m.user_id] || m.user?.full_name || m.user?.username || m.full_name || m.username || m.user_id,
value: m.user_id, value: m.user_id,
})); }));
}); });
+6 -10
View File
@@ -80,7 +80,6 @@ import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudyDetail } from "../../api/studies"; import { fetchStudyDetail } from "../../api/studies";
import { deleteSite, fetchSites, updateSite } from "../../api/sites"; import { deleteSite, fetchSites, updateSite } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members"; import { listMembers } from "../../api/members";
import SiteForm from "./SiteForm.vue"; import SiteForm from "./SiteForm.vue";
import type { Site, Study, UserInfo } from "../../types/api"; import type { Site, Study, UserInfo } from "../../types/api";
@@ -125,12 +124,9 @@ const loadSites = async () => {
}; };
const loadUsers = async () => { const loadUsers = async () => {
try { users.value = members.value
const { data } = await fetchUsers({ limit: 500 }); .map((member) => member.user)
users.value = (data as any).items || []; .filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
} catch {
users.value = [];
}
}; };
const loadMembers = async () => { const loadMembers = async () => {
@@ -138,8 +134,10 @@ const loadMembers = async () => {
try { try {
const { data } = await listMembers(projectId.value, { limit: 500 }); const { data } = await listMembers(projectId.value, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || []; members.value = Array.isArray(data) ? data : data.items || [];
loadUsers();
} catch { } catch {
members.value = []; members.value = [];
users.value = [];
} }
}; };
@@ -149,7 +147,7 @@ const memberNameMap = computed(() => {
map[u.id] = u.full_name || u.username || u.id; map[u.id] = u.full_name || u.username || u.id;
}); });
members.value.forEach((m) => { members.value.forEach((m) => {
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id; if (m.user_id && !map[m.user_id]) map[m.user_id] = m.user?.full_name || m.user?.username || m.full_name || m.username || m.user_id;
}); });
return map; return map;
}); });
@@ -194,14 +192,12 @@ const siteRowClass = ({ row }: { row: Site }) => {
}; };
const openCreate = () => { const openCreate = () => {
loadUsers();
loadMembers(); loadMembers();
editingSite.value = null; editingSite.value = null;
formVisible.value = true; formVisible.value = true;
}; };
const openEdit = (row: Site) => { const openEdit = (row: Site) => {
loadUsers();
loadMembers(); loadMembers();
editingSite.value = row; editingSite.value = row;
formVisible.value = true; formVisible.value = true;