清理全局角色残留并修复项目权限判断
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user