完善项目级权限与项目管理入口
- 新增项目成员候选用户接口,避免 PM 读取全局用户列表 - 按项目角色和权限矩阵控制项目管理、成员、中心与审计入口 - 合并侧边栏管理后台入口,统一从项目管理进入成员等模块 - 加强成员管理安全约束,禁止 PM 修改自己、系统管理员或更高权限角色 - 修复多项目角色缓存串用问题,并补充前后端回归测试
This commit is contained in:
@@ -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