完善项目级权限与项目管理入口
- 新增项目成员候选用户接口,避免 PM 读取全局用户列表 - 按项目角色和权限矩阵控制项目管理、成员、中心与审计入口 - 合并侧边栏管理后台入口,统一从项目管理进入成员等模块 - 加强成员管理安全约束,禁止 PM 修改自己、系统管理员或更高权限角色 - 修复多项目角色缓存串用问题,并补充前后端回归测试
This commit is contained in:
@@ -10,10 +10,20 @@ from app.crud import member as member_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate
|
||||
from app.schemas.user import UserDisplay
|
||||
from app.schemas.user import UserResponse
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
"/",
|
||||
response_model=StudyMemberRead,
|
||||
@@ -35,8 +83,16 @@ async def add_member(
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudyMemberRead:
|
||||
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)
|
||||
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:
|
||||
updated = await member_crud.update_member(
|
||||
db,
|
||||
@@ -106,12 +162,28 @@ async def list_members(
|
||||
role_in_study=m.role_in_study,
|
||||
is_active=m.is_active,
|
||||
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
|
||||
|
||||
|
||||
@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(
|
||||
"/{member_id}",
|
||||
response_model=StudyMemberRead,
|
||||
@@ -128,6 +200,13 @@ async def update_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="成员不存在")
|
||||
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}
|
||||
updated = await member_crud.update_member(db, member, member_in)
|
||||
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)
|
||||
if not member or member.study_id != study_id:
|
||||
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}
|
||||
removed = await member_crud.remove_member(db, member)
|
||||
await audit_crud.log_action(
|
||||
|
||||
@@ -6,7 +6,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.crud import audit as audit_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -33,7 +33,7 @@ async def get_permissions(
|
||||
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(
|
||||
study_id: uuid.UUID,
|
||||
payload: ProjectRolePermissionsUpdate,
|
||||
|
||||
@@ -85,6 +85,29 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque
|
||||
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:
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
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"]
|
||||
|
||||
@@ -31,4 +31,4 @@ class StudyMemberRead(BaseModel):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
expected = {
|
||||
"overview.py": [
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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>) =>
|
||||
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 }) =>
|
||||
apiPost<StudyMember>(`/api/v1/studies/${studyId}/members/`, payload);
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<span>{{ TEXT.menu.workbench }}</span>
|
||||
</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>
|
||||
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
|
||||
</template>
|
||||
<el-menu-item index="/admin/users">
|
||||
<el-menu-item v-if="isAdmin" index="/admin/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.accountManagement }}</span>
|
||||
</el-menu-item>
|
||||
@@ -35,24 +35,6 @@
|
||||
</el-menu-item>
|
||||
</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">
|
||||
<template #title>
|
||||
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
|
||||
@@ -244,11 +226,6 @@ const route = useRoute();
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
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) =>
|
||||
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||||
|
||||
@@ -9,19 +9,19 @@ describe("admin project route permissions", () => {
|
||||
const source = readRouter();
|
||||
|
||||
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: "audit_export", action: "read" }');
|
||||
expect(source).toContain("meta: { title: TEXT.menu.projectManagement }");
|
||||
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();
|
||||
|
||||
expect(source).toContain("const findPmAdminLandingPath = async");
|
||||
expect(source).toContain("pmAdminLandingModules.find");
|
||||
expect(source).toContain("const pmAdminLanding = isAdmin ? null : await findPmAdminLandingPath()");
|
||||
expect(source).toContain("? pmAdminLanding");
|
||||
expect(source).toContain("`/admin/projects/${studyId}/members`");
|
||||
expect(source).not.toContain("findPmAdminLandingPath");
|
||||
expect(source).not.toContain("pmAdminLandingModules");
|
||||
expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/workbench",');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -427,7 +427,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: "projects",
|
||||
name: "AdminProjects",
|
||||
component: AdminProjects,
|
||||
meta: { title: TEXT.menu.projectManagement, requiresAdmin: true },
|
||||
meta: { title: TEXT.menu.projectManagement },
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId",
|
||||
@@ -445,7 +445,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: "projects/:projectId/permissions",
|
||||
name: "AdminProjectPermissions",
|
||||
component: ProjectPermissions,
|
||||
meta: { title: "权限管理", requiresAdmin: true },
|
||||
meta: { title: "权限管理", adminProjectPermission: { module: "project_members", action: "write" } },
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId/sites",
|
||||
@@ -472,25 +472,6 @@ const router = createRouter({
|
||||
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) => {
|
||||
if (isAdmin) return true;
|
||||
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 || "";
|
||||
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) => {
|
||||
@@ -549,23 +530,13 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" });
|
||||
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 (!auth.forceLogin) {
|
||||
const pmAdminLanding = isAdmin ? null : await findPmAdminLandingPath().catch(() => null);
|
||||
next({
|
||||
path: isAdmin
|
||||
? studyStore.currentStudy
|
||||
? "/project/overview"
|
||||
: "/admin/users"
|
||||
: pmAdminLanding
|
||||
? pmAdminLanding
|
||||
: studyStore.currentStudy
|
||||
? "/project/overview"
|
||||
: "/workbench",
|
||||
|
||||
@@ -83,4 +83,54 @@ describe("study store startup rehydration", () => {
|
||||
expect(study.currentStudyRole).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,13 +44,10 @@ export const useStudyStore = defineStore("study", () => {
|
||||
currentStudy.value = study;
|
||||
currentSite.value = null; // 切换项目时清空中心
|
||||
localStorage.removeItem(SITE_KEY);
|
||||
currentStudy.value = study;
|
||||
const role = (study as any).role_in_study || null;
|
||||
currentStudyRole.value = role;
|
||||
localStorage.setItem(STUDY_KEY, JSON.stringify(study));
|
||||
if (role) {
|
||||
localStorage.setItem(STUDY_ROLE_KEY, role);
|
||||
}
|
||||
localStorage.removeItem(STUDY_ROLE_KEY);
|
||||
currentPermissions.value = null;
|
||||
const loginEmail = localStorage.getItem("ctms_last_login_email") || "";
|
||||
if (loginEmail) {
|
||||
@@ -67,8 +64,8 @@ export const useStudyStore = defineStore("study", () => {
|
||||
currentStudy.value = null;
|
||||
}
|
||||
}
|
||||
const savedRole = localStorage.getItem(STUDY_ROLE_KEY);
|
||||
currentStudyRole.value = savedRole || null;
|
||||
currentStudyRole.value = (currentStudy.value as any)?.role_in_study || null;
|
||||
localStorage.removeItem(STUDY_ROLE_KEY);
|
||||
|
||||
const savedSite = localStorage.getItem(SITE_KEY);
|
||||
if (savedSite) {
|
||||
@@ -200,11 +197,11 @@ export const useStudyStore = defineStore("study", () => {
|
||||
|
||||
const setCurrentStudyRole = (role: string | null) => {
|
||||
currentStudyRole.value = role;
|
||||
if (role) {
|
||||
localStorage.setItem(STUDY_ROLE_KEY, role);
|
||||
} else {
|
||||
localStorage.removeItem(STUDY_ROLE_KEY);
|
||||
if (currentStudy.value) {
|
||||
currentStudy.value = { ...currentStudy.value, role_in_study: role };
|
||||
localStorage.setItem(STUDY_KEY, JSON.stringify(currentStudy.value));
|
||||
}
|
||||
localStorage.removeItem(STUDY_ROLE_KEY);
|
||||
};
|
||||
|
||||
const setCurrentSite = (site: any | null) => {
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface StudyMember {
|
||||
role_in_study: string;
|
||||
is_active: boolean;
|
||||
added_at: string;
|
||||
user?: Partial<UserInfo> | null;
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
|
||||
@@ -13,7 +13,8 @@ describe("audit logs access", () => {
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("study.currentStudyRole");
|
||||
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('role !== "ADMIN"');
|
||||
});
|
||||
|
||||
@@ -216,11 +216,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const canProjectExport = computed(() => {
|
||||
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(() => {
|
||||
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 = () => {
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<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 }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -39,23 +39,23 @@
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@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.CRA" value="CRA" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
|
||||
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
|
||||
<el-option :label="TEXT.enums.userRole.QA" value="QA" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
|
||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" />
|
||||
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" />
|
||||
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" />
|
||||
<el-option :label="TEXT.enums.userRole.QA" value="QA" :disabled="!canAssignRole('QA')" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
|
||||
</el-select>
|
||||
<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
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
:disabled="!canEditMember(scope.row) || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
@click="toggleActive(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
||||
@@ -81,13 +81,13 @@
|
||||
</el-form-item>
|
||||
<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-option :label="TEXT.enums.userRole.PM" value="PM" />
|
||||
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
|
||||
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
|
||||
<el-option :label="TEXT.enums.userRole.QA" value="QA" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
|
||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" />
|
||||
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" />
|
||||
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" />
|
||||
<el-option :label="TEXT.enums.userRole.QA" value="QA" :disabled="!canAssignRole('QA')" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -103,18 +103,19 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { addMember, listMembers, removeMember, updateMember } from "../../api/members";
|
||||
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "../../api/members";
|
||||
import { fetchStudyDetail } from "../../api/studies";
|
||||
import type { Study, StudyMember, UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import { displayDateTime, displayEnum } from "../../utils/display";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
|
||||
const route = useRoute();
|
||||
const projectId = computed(() => route.params.projectId as string);
|
||||
const auth = useAuthStore();
|
||||
const permission = usePermission();
|
||||
|
||||
const project = ref<Study | null>(null);
|
||||
const members = ref<StudyMember[]>([]);
|
||||
@@ -127,6 +128,26 @@ const newMember = reactive({
|
||||
user_id: "",
|
||||
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>({
|
||||
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
|
||||
@@ -149,6 +170,11 @@ const loadMembers = async () => {
|
||||
try {
|
||||
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
|
||||
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) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
|
||||
} finally {
|
||||
@@ -157,9 +183,13 @@ const loadMembers = async () => {
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
if (!canManageMembers.value || !projectId.value) {
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = (data as any).items || [];
|
||||
const { data } = await listMemberCandidates(projectId.value, { limit: 500 });
|
||||
users.value = data || [];
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
@@ -167,7 +197,7 @@ const loadUsers = async () => {
|
||||
|
||||
const memberRows = computed(() =>
|
||||
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";
|
||||
return {
|
||||
...m,
|
||||
@@ -178,8 +208,9 @@ const memberRows = computed(() =>
|
||||
);
|
||||
|
||||
const openAdd = () => {
|
||||
if (!canManageMembers.value) return;
|
||||
newMember.user_id = "";
|
||||
newMember.role_in_study = "PM";
|
||||
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
|
||||
addVisible.value = true;
|
||||
};
|
||||
|
||||
@@ -194,13 +225,17 @@ const submitAdd = async () => {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
if (!canAssignRole(newMember.role_in_study)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
await addFormRef.value?.validate();
|
||||
adding.value = true;
|
||||
try {
|
||||
await addMember(projectId.value, newMember);
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
|
||||
addVisible.value = false;
|
||||
loadMembers();
|
||||
await Promise.all([loadMembers(), loadUsers()]);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
|
||||
} finally {
|
||||
@@ -210,6 +245,12 @@ const submitAdd = async () => {
|
||||
|
||||
const updateRole = async (memberId: string, role: string) => {
|
||||
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({
|
||||
actorRole: auth.user?.role || null,
|
||||
requiredPermission: "project.members.manage",
|
||||
@@ -231,6 +272,10 @@ const updateRole = async (memberId: string, role: string) => {
|
||||
|
||||
const toggleActive = async (row: StudyMember) => {
|
||||
if (!projectId.value) return;
|
||||
if (!canEditMember(row)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
requiredPermission: "project.members.manage",
|
||||
@@ -266,6 +311,10 @@ const toggleActive = async (row: StudyMember) => {
|
||||
|
||||
const onDelete = async (row: StudyMember) => {
|
||||
if (!projectId.value) return;
|
||||
if (!canEditMember(row)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
requiredPermission: "project.members.manage",
|
||||
@@ -284,6 +333,7 @@ const onDelete = async (row: StudyMember) => {
|
||||
try {
|
||||
await removeMember(projectId.value, row.id);
|
||||
members.value = members.value.filter((m) => m.id !== row.id);
|
||||
await loadUsers();
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
@@ -296,8 +346,8 @@ const availableUsers = computed(() => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProject(), loadUsers()]);
|
||||
loadMembers();
|
||||
await Promise.all([loadProject(), loadMembers()]);
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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')");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<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 }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<template #default="scope">
|
||||
<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-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-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-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-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
|
||||
</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
|
||||
link
|
||||
type="info"
|
||||
@@ -54,7 +57,7 @@
|
||||
@click="handleLockToggle(scope.row)"
|
||||
/>
|
||||
</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-tooltip>
|
||||
</div>
|
||||
@@ -68,14 +71,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
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 type { Study } from "../../types/api";
|
||||
import { fetchProjectRolePermissions } from "../../api/projectPermissions";
|
||||
import type { ProjectRolePermissionsResponse, Study } from "../../types/api";
|
||||
import ProjectForm from "./ProjectForm.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const projects = ref<Study[]>([]);
|
||||
@@ -84,6 +90,9 @@ const formVisible = ref(false);
|
||||
const editingProject = ref<Study | null>(null);
|
||||
const router = useRouter();
|
||||
const studyStore = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const permissionsByProject = ref<Record<string, ProjectRolePermissionsResponse>>({});
|
||||
|
||||
const loadProjects = async () => {
|
||||
loading.value = true;
|
||||
@@ -91,6 +100,19 @@ const loadProjects = async () => {
|
||||
const { data } = await fetchStudies();
|
||||
const items = (data as any).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) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
|
||||
} finally {
|
||||
@@ -99,10 +121,17 @@ const loadProjects = async () => {
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
if (!isAdmin.value) return;
|
||||
editingProject.value = null;
|
||||
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) => {
|
||||
router.push(`/admin/projects/${row.id}/members`);
|
||||
};
|
||||
@@ -115,7 +144,12 @@ const goPermissions = (row: Study) => {
|
||||
router.push(`/admin/projects/${row.id}/permissions`);
|
||||
};
|
||||
|
||||
const goAuditLogs = async (row: Study) => {
|
||||
await enterStudy(row, "/admin/audit-logs");
|
||||
};
|
||||
|
||||
const handleDelete = async (study: Study) => {
|
||||
if (!isAdmin.value) return;
|
||||
try {
|
||||
// 第一次确认
|
||||
await ElMessageBox.confirm(
|
||||
@@ -165,6 +199,7 @@ const handleDelete = async (study: Study) => {
|
||||
};
|
||||
|
||||
const handleLockToggle = async (study: Study) => {
|
||||
if (!isAdmin.value) return;
|
||||
// 锁定后不可解锁
|
||||
if (study.is_locked) {
|
||||
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);
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
router.push("/project/overview");
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const goDetail = (row: Study) => {
|
||||
router.push(`/admin/projects/${row.id}`);
|
||||
};
|
||||
|
||||
const goProject = (row: Study) => {
|
||||
if (isAdmin.value) {
|
||||
goDetail(row);
|
||||
return;
|
||||
}
|
||||
enterStudy(row);
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) =>
|
||||
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
|
||||
|
||||
|
||||
@@ -17,3 +17,15 @@ describe("Admin site form phone field", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ const craOptions = computed(() => {
|
||||
return acc;
|
||||
}, {});
|
||||
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,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -80,7 +80,6 @@ import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { fetchStudyDetail } from "../../api/studies";
|
||||
import { deleteSite, fetchSites, updateSite } from "../../api/sites";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { listMembers } from "../../api/members";
|
||||
import SiteForm from "./SiteForm.vue";
|
||||
import type { Site, Study, UserInfo } from "../../types/api";
|
||||
@@ -125,12 +124,9 @@ const loadSites = async () => {
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = (data as any).items || [];
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
users.value = members.value
|
||||
.map((member) => member.user)
|
||||
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
|
||||
};
|
||||
|
||||
const loadMembers = async () => {
|
||||
@@ -138,8 +134,10 @@ const loadMembers = async () => {
|
||||
try {
|
||||
const { data } = await listMembers(projectId.value, { limit: 500 });
|
||||
members.value = Array.isArray(data) ? data : data.items || [];
|
||||
loadUsers();
|
||||
} catch {
|
||||
members.value = [];
|
||||
users.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,7 +147,7 @@ const memberNameMap = computed(() => {
|
||||
map[u.id] = u.full_name || u.username || u.id;
|
||||
});
|
||||
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;
|
||||
});
|
||||
@@ -194,14 +192,12 @@ const siteRowClass = ({ row }: { row: Site }) => {
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
loadUsers();
|
||||
loadMembers();
|
||||
editingSite.value = null;
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: Site) => {
|
||||
loadUsers();
|
||||
loadMembers();
|
||||
editingSite.value = row;
|
||||
formVisible.value = true;
|
||||
|
||||
Reference in New Issue
Block a user