清理全局角色残留并修复项目权限判断

This commit is contained in:
Cheng Zhou
2026-05-29 10:20:42 +08:00
parent 63457aab11
commit 44d69c2d7b
63 changed files with 739 additions and 443 deletions
+4 -2
View File
@@ -15,8 +15,10 @@ describe("audit logs access", () => {
expect(source).toContain('projectRole.value === "PM"');
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"');
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]');
expect(source).toContain('if (scope === "project" && !canProjectExport.value) return;');
expect(source).not.toMatch(/auth\.user\?\.role/);
expect(source).not.toContain('!== "ADMIN"');
});
it("does not expose legacy startup ethics business labels", () => {
+2 -1
View File
@@ -328,7 +328,7 @@ 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" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]);
});
const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true;
@@ -510,6 +510,7 @@ const handleExportCommand = (command: string) => {
const confirmExport = async (scope: "system" | "project") => {
const currentStudy = study.currentStudy;
if (!currentStudy) return;
if (scope === "project" && !canProjectExport.value) return;
const ok = await ElMessageBox.confirm(
TEXT.modules.adminAuditLogs.exportConfirm,
TEXT.modules.adminAuditLogs.exportConfirmTitle,
@@ -108,6 +108,20 @@ describe("permission management custom roles", () => {
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
});
it("uses granular project member permissions for member management actions", () => {
const source = readSource();
expect(source).toContain('selectedProjectPermissionAllowed("project_members:list")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:candidates")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
expect(source).toContain('v-if="canAddProjectMember"');
expect(source).toContain(":disabled=\"!canUpdateProjectMember(row)");
expect(source).toContain(":disabled=\"!canDeleteProjectMember(row)\"");
expect(source).not.toContain("project.members.manage");
});
it("uses QA and CTA as preset project permission role keys", () => {
const source = readSource();
@@ -173,13 +187,17 @@ describe("permission management custom roles", () => {
expect(source).not.toContain('subjects: "参与者基础信息"');
});
it("shows startup ethics permissions in role editor with business section names", () => {
it("shows startup ethics permissions in role editor with two business section names", () => {
const source = readSource();
const moduleSource = readProjectPermissionModulesSource();
expect(moduleSource).toContain('startup_ethics: "立项与伦理"');
expect(source).toContain('startup_initiation: "立项记录"');
expect(source).toContain('startup_ethics: "伦理记录"');
expect(source).toContain('startup_initiation: "立项"');
expect(source).toContain('startup_initiation_attachments: "立项"');
expect(source).toContain('startup_ethics: "伦理"');
expect(source).toContain('startup_ethics_attachments: "伦理"');
expect(source).not.toContain('startup_initiation: "立项记录"');
expect(source).not.toContain('startup_ethics: "伦理记录"');
});
it("does not expose the legacy dashboard module after merging into project overview", () => {
@@ -73,7 +73,7 @@
<el-tag v-if="memberRows.length" effect="plain" size="small" round>{{ memberRows.length }} 名成员</el-tag>
</div>
<div class="sub-tab-actions">
<el-button type="primary" @click="openAddMember">
<el-button v-if="canAddProjectMember" type="primary" @click="openAddMember">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
添加成员
</el-button>
@@ -96,7 +96,7 @@
size="small"
style="width: 130px"
@change="(val: string) => updateMemberRole(row.id, val)"
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
:disabled="!canUpdateProjectMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
>
<el-option
v-for="(label, val) in memberRoleLabels(row)"
@@ -128,12 +128,12 @@
link
:type="row.is_active ? 'warning' : 'primary'"
size="small"
:disabled="!canEditMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
:disabled="!canUpdateProjectMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleMemberActive(row)"
>
{{ row.is_active ? "停用" : "启用" }}
</el-button>
<el-button link type="danger" size="small" :disabled="!canEditMember(row)" @click="deleteMember(row)">
<el-button link type="danger" size="small" :disabled="!canDeleteProjectMember(row)" @click="deleteMember(row)">
移除
</el-button>
</template>
@@ -480,6 +480,18 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || "");
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const selectedProjectPermissionAllowed = (operationKey: string) => {
if (isAdmin.value) return true;
const role = selectedProjectRole.value;
if (!role || !apiMatrix.value) return false;
return isApiPermissionAllowed(apiMatrix.value?.[role]?.[operationKey]);
};
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:list"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:candidates"));
const canCreateProjectMember = computed(() => selectedProjectPermissionAllowed("project_members:create"));
const canUpdateProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:update"));
const canDeleteProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:delete"));
const canAddProjectMember = computed(() => canCreateProjectMember.value && canListProjectMemberCandidates.value);
const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
const ROLE_RANK: Record<string, number> = {
@@ -495,7 +507,7 @@ const assignableRoleLabels = computed(() => {
return result;
});
const memberRoleLabels = (row: MemberRoleRow) => {
if (canEditMember(row)) return assignableRoleLabels.value;
if (canUpdateProjectMember(row)) return assignableRoleLabels.value;
return {
...assignableRoleLabels.value,
[row.role_in_study]: roleLabel(row.role_in_study),
@@ -574,7 +586,8 @@ const onStudyChange = async (id: string) => {
candidates.value = [];
activeRolesDraft.value = [];
router.replace({ query: { ...route.query, projectId: id } });
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
await loadPermissionData();
await Promise.all([loadMembers(), loadActiveRoles()]);
loadCandidates();
};
@@ -617,13 +630,14 @@ const addMemberRules: FormRules = {
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
};
const canEditMember = (row: MemberRoleRow) => {
if (!canManageSelectedProject.value) return false;
const canMutateProjectMember = (row: MemberRoleRow) => {
if (row.user_id === auth.user?.id) return false;
if (row.user?.is_admin) return false;
if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false;
return true;
};
const canUpdateProjectMember = (row: MemberRoleRow) => canUpdateProjectMembers.value && canMutateProjectMember(row);
const canDeleteProjectMember = (row: MemberRoleRow) => canDeleteProjectMembers.value && canMutateProjectMember(row);
const memberRows = computed(() =>
members.value.map((m) => {
@@ -639,7 +653,7 @@ const availableCandidates = computed(() => {
});
const loadMembers = async () => {
if (!selectedStudyId.value) return;
if (!canListProjectMembers.value || !selectedStudyId.value) return;
membersLoading.value = true;
try {
const { data } = await listMembers(selectedStudyId.value, { limit: 500, include_inactive: true });
@@ -652,7 +666,10 @@ const loadMembers = async () => {
};
const loadCandidates = async () => {
if (!selectedStudyId.value) return;
if (!canListProjectMemberCandidates.value || !selectedStudyId.value) {
candidates.value = [];
return;
}
try {
const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 });
candidates.value = data ?? [];
@@ -662,6 +679,7 @@ const loadCandidates = async () => {
};
const openAddMember = () => {
if (!canAddProjectMember.value) return;
addMemberForm.user_id = "";
addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";
addMemberVisible.value = true;
@@ -670,6 +688,10 @@ const openAddMember = () => {
const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); };
const submitAddMember = async () => {
if (!canAddProjectMember.value) {
ElMessage.warning("无权限添加成员");
return;
}
const valid = await addMemberFormRef.value?.validate().catch(() => false);
if (!valid) return;
addMemberSaving.value = true;
@@ -686,6 +708,12 @@ const submitAddMember = async () => {
};
const updateMemberRole = async (memberId: string, role: string) => {
const row = members.value.find((item) => item.id === memberId);
if (!row || !canUpdateProjectMember(row)) {
ElMessage.warning("无权限更新成员");
loadMembers();
return;
}
try {
await updateMember(selectedStudyId.value, memberId, { role_in_study: role });
ElMessage.success("角色已更新");
@@ -696,6 +724,10 @@ const updateMemberRole = async (memberId: string, role: string) => {
};
const toggleMemberActive = async (row: any) => {
if (!canUpdateProjectMember(row)) {
ElMessage.warning("无权限更新成员");
return;
}
if (row.is_active) {
const ok = await ElMessageBox.confirm("确认停用该成员?", "停用成员", { type: "warning" }).catch(() => null);
if (!ok) return;
@@ -710,6 +742,10 @@ const toggleMemberActive = async (row: any) => {
};
const deleteMember = async (row: any) => {
if (!canDeleteProjectMember(row)) {
ElMessage.warning("无权限移除成员");
return;
}
const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "移除成员", {
type: "warning",
}).catch(() => null);
@@ -973,8 +1009,10 @@ const PERMISSION_SECTION_LABELS: Record<string, string> = {
subject_histories: "病史",
monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑",
startup_initiation: "立项记录",
startup_ethics: "伦理记录",
startup_initiation: "立项",
startup_initiation_attachments: "立项",
startup_ethics: "伦理",
startup_ethics_attachments: "伦理",
precautions: "注意事项",
precautions_attachments: "注意事项",
faq: "医学咨询/FAQ",
@@ -1147,7 +1185,8 @@ onMounted(async () => {
if (initialStudyId) {
selectedStudyId.value = initialStudyId;
if (qSub === "members") projectSubTab.value = "members";
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
await loadPermissionData();
await Promise.all([loadMembers(), loadActiveRoles()]);
loadCandidates();
}
+27 -1
View File
@@ -88,6 +88,32 @@ describe("project detail management role", () => {
expect(source).toContain("isSystemAdmin(authStore.user)");
expect(source).not.toContain('setupRole.value === "ADMIN"');
expect(source).not.toContain("project.value?.role_in_study || authStore.user?.role");
expect(source).not.toMatch(/authStore\.user\?\.role/);
});
it("uses fine-grained setup config permissions for each setup action", () => {
const source = readProjectDetail();
expect(source).toContain("const canReadSetupConfig = computed");
expect(source).toContain("const canUpdateSetupConfig = computed");
expect(source).toContain("const canPublishSetupConfig = computed");
expect(source).toContain("const canRollbackSetupConfig = computed");
expect(source).toContain("const canDeleteSetupVersion = computed");
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:read")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:update")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:publish")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:rollback")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:delete_version")');
expect(source).toContain('v-if="canUpdateSetupConfig" type="success"');
expect(source).toContain('v-if="canPublishSetupConfig" type="warning"');
expect(source).toContain('v-if="canRollbackSetupConfig"');
expect(source).toContain('v-if="canDeleteSetupVersion"');
expect(source).toContain("if (!canReadSetupConfig.value)");
expect(source).toContain("if (!canUpdateSetupConfig.value)");
expect(source).toContain("if (!canPublishAction.value) return");
expect(source).toContain("if (!canRollbackSetupConfig.value) return");
expect(source).toContain("if (!canDeleteSetupVersion.value) return");
expect(source).not.toContain("const hasSetupReadPermission = computed");
expect(source).not.toContain("const canManageSetup = computed");
});
});
+34 -30
View File
@@ -56,10 +56,10 @@
</div>
<div class="setup-toolbar-actions">
<el-button v-if="canManageSetup" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button v-if="canManageSetup" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
<el-button v-if="canManageSetup" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
<el-button v-if="canManageSetup" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
<el-button v-if="canUpdateSetupConfig" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button v-if="canUpdateSetupConfig" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
<el-button v-if="canUpdateSetupConfig" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
<el-button v-if="canPublishSetupConfig" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
<el-button
type="info"
plain
@@ -1498,18 +1498,18 @@
</div>
<div class="rollback-axis-actions">
<el-tooltip content="回滚到此版本" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-danger" size="small" @click="selectRollbackVersion(row.version)">
<el-button v-if="canRollbackSetupConfig" class="vm-action-btn vm-action-danger" size="small" @click="selectRollbackVersion(row.version)">
<el-icon :size="13"><RefreshLeft /></el-icon>
回滚
</el-button>
</el-tooltip>
<el-tooltip v-if="(row.branch_name || 'main') === 'main'" content="基于此版本新建分支草稿" placement="top" :show-after="300">
<el-tooltip v-if="canUpdateSetupConfig && (row.branch_name || 'main') === 'main'" content="基于此版本新建分支草稿" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-primary" size="small" @click="checkoutBranchDraftFromVersion(row.version)">
<el-icon :size="13"><DocumentCopy /></el-icon>
新建分支草稿
</el-button>
</el-tooltip>
<el-tooltip v-if="(row.branch_name || 'main') !== 'main'" content="合并至主线创建新版本" placement="top" :show-after="300">
<el-tooltip v-if="canPublishSetupConfig && (row.branch_name || 'main') !== 'main'" content="合并至主线创建新版本" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-primary" size="small" @click="mergeVersionToMain(row.version)">
<el-icon :size="13"><Connection /></el-icon>
合并主线新版本
@@ -1521,7 +1521,7 @@
下载
</el-button>
</el-tooltip>
<el-tooltip content="删除此版本" placement="top" :show-after="300">
<el-tooltip v-if="canDeleteSetupVersion" content="删除此版本" placement="top" :show-after="300">
<el-button
class="vm-action-btn vm-action-danger"
size="small"
@@ -2405,20 +2405,18 @@ const setupDraft = reactive<SetupConfigDraft>({
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
const projectPermissions = ref<Record<string, Record<string, any>> | null>(null);
const hasSetupReadPermission = computed(() => {
const selectedProjectPermissionAllowed = (operationKey: string) => {
if (isSystemAdmin(authStore.user)) return true;
if (!projectPermissions.value) return false;
const rolePerms = Object.values(projectPermissions.value)[0];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
});
const canManageSetup = computed(() => {
if (isSystemAdmin(authStore.user)) return true;
if (!projectPermissions.value) return false;
const rolePerms = Object.values(projectPermissions.value)[0];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
});
return isApiPermissionAllowed(rolePerms[operationKey]);
};
const canReadSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:read"));
const canUpdateSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:update"));
const canPublishSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:publish"));
const canRollbackSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:rollback"));
const canDeleteSetupVersion = computed(() => selectedProjectPermissionAllowed("setup_config:delete_version"));
const isPreviewView = computed(() => setupViewMode.value === "preview");
const isPublishedVersionView = computed(() => setupViewMode.value === "published");
const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value);
@@ -2429,7 +2427,7 @@ const currentSetupDraft = computed<SetupConfigDraft>(() => {
return setupDraft;
});
const canEditSetup = computed(
() => Boolean(project.value && !project.value?.is_locked && canManageSetup.value && !isPublishedView.value)
() => Boolean(project.value && !project.value?.is_locked && canUpdateSetupConfig.value && !isPublishedView.value)
);
const canMutateDraft = () => canEditSetup.value && !isPublishedView.value;
const canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) =>
@@ -2482,8 +2480,8 @@ const closeIndexedEditor = (controller: IndexedEditorController, successMessage:
controller.index.value = -1;
ElMessage.success(successMessage);
};
const canSaveDraftAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked && !isPublishedView.value));
const canPublishAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked));
const canSaveDraftAction = computed(() => Boolean(project.value && canUpdateSetupConfig.value && !project.value?.is_locked && !isPublishedView.value));
const canPublishAction = computed(() => Boolean(project.value && canPublishSetupConfig.value && !project.value?.is_locked));
const currentStepTitle = computed(() => `${activeStep.value + 1}步:${steps[activeStep.value]?.label || ""}`);
const setupPublishedVersionText = computed(() => {
return setupPublishedVersion.value || "v0";
@@ -4397,7 +4395,7 @@ const loadProject = async () => {
project.value = data as Study;
projectPermissions.value = permRes.data as any;
if (!hasSetupReadPermission.value) {
if (!canReadSetupConfig.value) {
ElMessage.warning("当前角色无权访问立项配置");
router.replace("/admin/projects");
return;
@@ -4450,7 +4448,7 @@ const startEdit = () => {
ElMessage.info("当前为只读视图,请先切换到草稿后再编辑");
return;
}
if (!canManageSetup.value) {
if (!canUpdateSetupConfig.value) {
ElMessage.warning("当前角色无权编辑立项配置");
return;
}
@@ -4634,7 +4632,8 @@ const saveConfigNow = async (): Promise<boolean> => {
};
const doPublishConfigNow = async (): Promise<boolean> => {
if (!project.value || !canPublishAction.value) return false;
if (!project.value) return false;
if (!canPublishAction.value) return false;
try {
if (hasFormUnsavedChanges.value) {
syncProjectInfoIntoSetupDraft();
@@ -4695,7 +4694,8 @@ const doPublishConfigNow = async (): Promise<boolean> => {
};
const publishConfigNow = async () => {
if (!project.value || !canManageSetup.value) return;
if (!project.value) return;
if (!canPublishAction.value) return;
reconcileDraftSyncStateBeforePublish();
if (hasSetupDraftUnsavedChanges.value) {
ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”");
@@ -4960,13 +4960,14 @@ const isLatestPublishedVersion = (rawVersion: number): boolean => {
};
const openRollbackDialog = async () => {
if (!project.value || !canManageSetup.value) return;
if (!project.value || !canReadSetupConfig.value) return;
await loadSetupVersionHistory();
rollbackDialogVisible.value = true;
};
const selectRollbackVersion = async (targetVersion: number) => {
if (!project.value) return;
if (!canRollbackSetupConfig.value) return;
const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
try {
await ElMessageBox.confirm(
@@ -5018,6 +5019,7 @@ const selectRollbackVersion = async (targetVersion: number) => {
const checkoutBranchDraftFromVersion = async (targetVersion: number) => {
if (!project.value) return;
if (!canUpdateSetupConfig.value) return;
const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
try {
await ElMessageBox.confirm(
@@ -5344,6 +5346,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
const removeVersion = async (targetVersion: number) => {
if (!project.value) return;
if (!canDeleteSetupVersion.value) return;
if (isLatestPublishedVersion(targetVersion)) {
ElMessage.warning("当前发布版本不能删除");
return;
@@ -5372,6 +5375,7 @@ const removeVersion = async (targetVersion: number) => {
const mergeVersionToMain = async (sourceVersion: number) => {
if (!project.value) return;
if (!canPublishSetupConfig.value) return;
const sourceVersionLabel = getDisplayVersionLabel(sourceVersion);
try {
await ElMessageBox.confirm(
@@ -5420,7 +5424,7 @@ const mergeVersionToMain = async (sourceVersion: number) => {
};
const handleRefillDraft = async () => {
if (!project.value || !canManageSetup.value) return;
if (!project.value || !canUpdateSetupConfig.value) return;
try {
await ElMessageBox.confirm(
`确认将草稿一键回填为当前发布版本 ${setupPublishedVersionText.value} 的内容?`,
@@ -5468,7 +5472,7 @@ const handleRefillDraft = async () => {
};
const handleClearDraft = async () => {
if (!project.value || !canManageSetup.value) return;
if (!project.value || !canUpdateSetupConfig.value) return;
try {
await ElMessageBox.confirm(
"确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。",
@@ -5521,7 +5525,7 @@ const ensureCanSaveDraftAction = (): boolean => {
ElMessage.warning("项目信息尚未加载完成,请稍后重试");
return false;
}
if (!canManageSetup.value) {
if (!canUpdateSetupConfig.value) {
ElMessage.warning("当前角色无权保存立项配置");
return false;
}
@@ -5641,7 +5645,7 @@ const ensureStep2Editable = (): boolean => {
ElMessage.info("当前为只读视图,请先切换到草稿后再操作");
return false;
}
if (!canManageSetup.value) {
if (!canUpdateSetupConfig.value) {
ElMessage.warning("当前角色无权编辑立项配置");
return false;
}
@@ -10,11 +10,17 @@ describe("ProjectMembers user directory access", () => {
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('const canListMembers = computed(() => permission.can("project.members.list"));');
expect(source).toContain('const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));');
expect(source).toContain('const canCreateMember = computed(() => permission.can("project.members.create"));');
expect(source).toContain('const canUpdateMember = computed(() => permission.can("project.members.update"));');
expect(source).toContain('const canDeleteMember = computed(() => permission.can("project.members.delete"));');
expect(source).toContain("if (!canListMembers.value");
expect(source).toContain("if (!canListMemberCandidates.value");
expect(source).toContain("return;");
expect(source).toContain('v-if="canManageMembers"');
expect(source).toContain('v-if="canAddMember"');
expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })");
expect(source).not.toContain("project.members.manage");
});
it("uses the member API embedded user data when the global directory is unavailable", () => {
@@ -34,6 +40,7 @@ describe("ProjectMembers user directory access", () => {
expect(source).toContain("const canAssignRole = (role: string)");
expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
expect(source).toContain(':disabled="!canEditMember(scope.row)');
expect(source).toContain(':disabled="!canDeleteProjectMember(scope.row)"');
});
it("uses permission template names for project role display options", () => {
+31 -19
View File
@@ -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 v-if="canManageMembers" type="primary" @click="openAdd">
<el-button v-if="canAddMember" type="primary" @click="openAdd">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
</el-button>
</div>
@@ -50,7 +50,7 @@
/>
</el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<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="danger" size="small" :disabled="!canDeleteProjectMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
@@ -130,7 +130,12 @@ const newMember = reactive({
user_id: "",
role_in_study: "PM",
});
const canManageMembers = computed(() => permission.can("project.members.manage"));
const canListMembers = computed(() => permission.can("project.members.list"));
const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));
const canCreateMember = computed(() => permission.can("project.members.create"));
const canUpdateMember = computed(() => permission.can("project.members.update"));
const canDeleteMember = computed(() => permission.can("project.members.delete"));
const canAddMember = computed(() => canCreateMember.value && canListMemberCandidates.value);
const projectRole = computed(() => project.value?.role_in_study || "");
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
@@ -144,13 +149,14 @@ const roleRank: Record<string, number> = {
};
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
const canEditMember = (row: StudyMember) => {
if (!canManageMembers.value) return false;
const canMutateProjectMember = (row: StudyMember) => {
if (row.user?.is_admin) return false;
if (auth.user?.is_admin) return true;
if (row.user_id === auth.user?.id) return false;
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
};
const canEditMember = (row: StudyMember) => canUpdateMember.value && canMutateProjectMember(row);
const canDeleteProjectMember = (row: StudyMember) => canDeleteMember.value && canMutateProjectMember(row);
const addRules = reactive<FormRules>({
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
@@ -168,16 +174,12 @@ const loadProject = async () => {
};
const loadMembers = async () => {
if (!projectId.value) return;
if (!canListMembers.value || !projectId.value) return;
loading.value = true;
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[];
}
if (!canListMemberCandidates.value) syncUsersFromMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
} finally {
@@ -185,9 +187,15 @@ const loadMembers = async () => {
}
};
const syncUsersFromMembers = () => {
users.value = members.value
.map((member) => member.user)
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
};
const loadUsers = async () => {
if (!canManageMembers.value || !projectId.value) {
users.value = [];
if (!canListMemberCandidates.value || !projectId.value) {
syncUsersFromMembers();
return;
}
try {
@@ -211,7 +219,7 @@ const memberRows = computed(() =>
);
const openAdd = () => {
if (!canManageMembers.value) return;
if (!canAddMember.value) return;
newMember.user_id = "";
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
addVisible.value = true;
@@ -219,9 +227,13 @@ const openAdd = () => {
const submitAdd = async () => {
if (!projectId.value) return;
if (!canAddMember.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage",
requiredPermission: "project.members.create",
target: { projectId: projectId.value },
});
if (!decision.allowed) {
@@ -256,7 +268,7 @@ const updateRole = async (memberId: string, role: string) => {
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage",
requiredPermission: "project.members.update",
target: { projectId: projectId.value, memberId },
});
if (!decision.allowed) {
@@ -281,7 +293,7 @@ const toggleActive = async (row: StudyMember) => {
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage",
requiredPermission: "project.members.update",
target: { projectId: projectId.value, memberId: row.id },
});
if (!decision.allowed) {
@@ -314,13 +326,13 @@ const toggleActive = async (row: StudyMember) => {
const onDelete = async (row: StudyMember) => {
if (!projectId.value) return;
if (!canEditMember(row)) {
if (!canDeleteProjectMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage",
requiredPermission: "project.members.delete",
target: { projectId: projectId.value, memberId: row.id },
});
if (!decision.allowed) {
+13 -3
View File
@@ -52,12 +52,22 @@ describe("project management access", () => {
expect(source).toContain("canProject(scope.row, 'audit_export', 'read')");
});
it("opens project names in management detail when the user has setup config write permission", () => {
it("opens project names in management detail when the user has setup config read permission", () => {
const source = readProjects();
expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'write')\"");
expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'read')\"");
expect(source).toContain('@click="goProject(scope.row)" class="project-name"');
expect(source).toContain('@click="enterStudy(scope.row)"');
expect(source).toContain('if (!canProject(row, "setup_config", "write"))');
expect(source).toContain('if (!canProject(row, "setup_config", "read"))');
});
it("keeps audit export write mappings on the export operation", () => {
const projects = readProjects();
const router = readRouter();
expect(projects).toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:export" }');
expect(router).toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:export" }');
expect(projects).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
expect(router).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
});
});
+3 -3
View File
@@ -74,7 +74,7 @@
</svg>
</div>
<div class="project-info">
<el-link v-if="canProject(scope.row, 'setup_config', 'write')" type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
<el-link v-if="canProject(scope.row, 'setup_config', 'read')" type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
<span v-else class="project-name project-name--disabled">{{ scope.row.name }}</span>
<span class="project-code">{{ scope.row.code || '-' }}</span>
</div>
@@ -210,7 +210,7 @@ const openCreate = () => {
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
setup_config: { read: "setup_config:read", write: "setup_config:update" },
};
@@ -335,7 +335,7 @@ const goDetail = (row: Study) => {
};
const goProject = (row: Study) => {
if (!canProject(row, "setup_config", "write")) {
if (!canProject(row, "setup_config", "read")) {
ElMessage.warning("当前角色无权访问该项目的立项配置");
return;
}