统一项目角色与接口权限配置

This commit is contained in:
Cheng Zhou
2026-05-28 10:48:33 +08:00
parent 6d1c98bcae
commit 2c85742040
45 changed files with 2863 additions and 1329 deletions
+2
View File
@@ -13,6 +13,8 @@ describe("permission project role model", () => {
expect(source).toContain("study.currentStudyRole");
expect(source).toContain("study.currentPermissions");
expect(source).toContain('"faq.edit": "faq:update"');
expect(source).toContain('"ae.create": "subject_aes:create"');
expect(source).toContain('"ae.close": "subject_aes:update"');
expect(source).toContain('"fees.contract.create": "fees_contracts:create"');
expect(source).toContain('"fees.contract.update": "fees_contracts:update"');
expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"');
+4 -7
View File
@@ -59,15 +59,14 @@ export const usePermission = () => {
"subject.enroll": "subjects:update",
"subject.complete": "subjects:update",
"subject.drop": "subjects:update",
"ae.create": "risk_issues:create",
"ae.close": "risk_issues:update",
"ae.create": "subject_aes:create",
"ae.close": "subject_aes:update",
"project.overview.read": "project_overview:read",
"faq.edit": "faq:update",
"faq.create": "faq:create",
"faq.reply": "faq_reply:create",
"knowledge.notes.write": "knowledge_notes:create",
"shared.library.read": "knowledge_notes:read",
"shared.library.write": "knowledge_notes:create",
"precautions.read": "precautions:read",
"precautions.write": "precautions:create",
"project.members.manage": "project_members:update",
"site.manage": "sites:update",
"site.cra.bind": "sites:update",
@@ -76,8 +75,6 @@ export const usePermission = () => {
"fees.contract.delete": "fees_contracts:delete",
"documents.create": "documents:create",
"documents.delete": "documents:delete",
"fees.attachment.delete": "fees_attachments:delete",
"file.attachment.delete": "attachments:delete",
"audit.export.read": "audit_logs:read",
};
const operationKey = permissionMap[action];
@@ -0,0 +1,69 @@
export const PROJECT_PERMISSION_MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理",
sites: "中心管理",
risk_issues: "风险问题",
project_members: "项目成员",
project_milestones: "项目里程碑",
project_overview: "项目总览",
setup_config: "立项配置",
fees: "合同费用",
materials: "物资管理",
documents: "文件版本",
startup_ethics: "立项与伦理",
startup_auth: "启动与授权",
audit_export: "审计日志",
shared_library: "共享库",
subject_histories: "病史记录",
};
const COMMON_PROJECT_PERMISSION_MODULES = new Set([
"setup_config",
"project_members",
"sites",
"audit_export",
]);
export const PROJECT_PERMISSION_CATEGORY_LABELS: Record<"common" | "business", string> = {
common: "通用权限",
business: "业务权限",
};
const PROJECT_MENU_MODULE_ORDER = [
"setup_config",
"project_members",
"sites",
"audit_export",
"project_overview",
"project_milestones",
"fees",
"materials",
"documents",
"startup_ethics",
"startup_auth",
"subjects",
"subject_histories",
"risk_issues",
"shared_library",
];
export const projectPermissionModuleLabel = (module: string): string =>
PROJECT_PERMISSION_MODULE_LABELS[module] || module;
export const projectPermissionCategory = (module: string): "common" | "business" =>
COMMON_PROJECT_PERMISSION_MODULES.has(module) ? "common" : "business";
export const projectPermissionCategoryLabel = (module: string): string =>
PROJECT_PERMISSION_CATEGORY_LABELS[projectPermissionCategory(module)];
export const getProjectPermissionModuleOrder = (module: string): number => {
const index = PROJECT_MENU_MODULE_ORDER.indexOf(module);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const compareProjectPermissionModules = (a: string, b: string): number => {
const categoryDiff = Number(projectPermissionCategory(a) === "business") - Number(projectPermissionCategory(b) === "business");
if (categoryDiff !== 0) return categoryDiff;
const orderDiff = getProjectPermissionModuleOrder(a) - getProjectPermissionModuleOrder(b);
if (orderDiff !== 0) return orderDiff;
return projectPermissionModuleLabel(a).localeCompare(projectPermissionModuleLabel(b), "zh-CN");
};
@@ -0,0 +1,11 @@
const PROJECT_ROLE_RANK: Record<string, number> = {
ADMIN: 100,
PM: 80,
QA: 60,
PV: 50,
CRA: 40,
CTA: 40,
};
export const compareProjectPermissionRoles = (a: string, b: string): number =>
(PROJECT_ROLE_RANK[b] ?? 0) - (PROJECT_ROLE_RANK[a] ?? 0) || a.localeCompare(b);
@@ -5,33 +5,41 @@ describe("project route permissions", () => {
it("maps project routes to backend operation keys", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" });
expect(getProjectRoutePermission("/finance/contracts/new")).toEqual({ operationKey: "finance_contracts:create" });
expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toEqual({ operationKey: "finance_contracts:update" });
expect(getProjectRoutePermission("/finance/contracts")).toBeNull();
expect(getProjectRoutePermission("/finance/contracts/new")).toBeNull();
expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/drug/shipments/new")).toEqual({ operationKey: "drug_shipments:create" });
expect(getProjectRoutePermission("/drug/shipments/abc/edit")).toEqual({ operationKey: "drug_shipments:update" });
expect(getProjectRoutePermission("/subjects/new")).toEqual({ operationKey: "subjects:create" });
expect(getProjectRoutePermission("/subjects/abc/edit")).toEqual({ operationKey: "subjects:update" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:create" });
expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "ethics:update" });
expect(getProjectRoutePermission("/risk-issues")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues/sae")).toEqual({ operationKey: "subject_aes:list" });
expect(getProjectRoutePermission("/risk-issues/pd")).toEqual({ operationKey: "subject_pds:list" });
expect(getProjectRoutePermission("/risk-issues/monitoring-visits")).toEqual({ operationKey: "monitoring_issues:list" });
expect(getProjectRoutePermission("/etmf")).toEqual({ operationKey: "documents:read" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "startup_initiation:create" });
expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "startup_initiation:update" });
expect(getProjectRoutePermission("/startup/ethics/new")).toEqual({ operationKey: "startup_ethics:create" });
expect(getProjectRoutePermission("/startup/ethics/abc/edit")).toEqual({ operationKey: "startup_ethics:update" });
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:create" });
expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toEqual({ operationKey: "startup_auth:update" });
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:create" });
expect(getProjectRoutePermission("/knowledge/notes/abc/edit")).toEqual({ operationKey: "knowledge_notes:update" });
expect(getProjectRoutePermission("/knowledge/precautions/new")).toEqual({ operationKey: "precautions:create" });
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toBeNull();
});
it("rejects project routes when the role lacks operation permission", () => {
const matrix = {
CRA: {
"project_overview:read": { allowed: false },
"knowledge_notes:read": { allowed: true },
"precautions:read": { allowed: true },
},
} as any;
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/project/overview"), false)).toBe(false);
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/notes"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/notes");
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/precautions"), false)).toBe(true);
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/precautions");
});
});
+13 -14
View File
@@ -7,39 +7,39 @@ export type ProjectRoutePermission = {
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/fees/contracts/new"], permission: { operationKey: "fees_contracts:create" } },
{ prefixes: ["/finance/contracts/new"], permission: { operationKey: "finance_contracts:create" } },
{ prefixes: ["/drug/shipments/new"], permission: { operationKey: "drug_shipments:create" } },
{ prefixes: ["/startup/feasibility/new", "/startup/ethics/new"], permission: { operationKey: "ethics:create" } },
{ prefixes: ["/startup/feasibility/new"], permission: { operationKey: "startup_initiation:create" } },
{ prefixes: ["/startup/ethics/new"], permission: { operationKey: "startup_ethics:create" } },
{ prefixes: ["/startup/kickoff/new", "/startup/training/new"], permission: { operationKey: "startup_auth:create" } },
{ prefixes: ["/subjects/new"], permission: { operationKey: "subjects:create" } },
{ prefixes: ["/knowledge/notes/new"], permission: { operationKey: "knowledge_notes:create" } },
{ prefixes: ["/knowledge/precautions/new"], permission: { operationKey: "precautions:create" } },
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } },
{ prefixes: ["/finance/contracts"], permission: { operationKey: "finance_contracts:read" } },
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "ethics:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility"], permission: { operationKey: "startup_initiation:read" } },
{ prefixes: ["/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "risk_issues:read" } },
{ prefixes: ["/monitoring-audit", "/monitoring", "/audit"], permission: { operationKey: "monitoring_audit:read" } },
{ prefixes: ["/risk-issues/monitoring-visits"], permission: { operationKey: "monitoring_issues:list" } },
{ prefixes: ["/risk-issues/pd"], permission: { operationKey: "subject_pds:list" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:list" } },
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
{ prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "knowledge_notes:read" } },
{ prefixes: ["/knowledge/precautions", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "precautions:read" } },
];
const editRoutePermissions: Array<{ pattern: RegExp; permission: ProjectRoutePermission }> = [
{ pattern: /^\/fees\/contracts\/[^/]+\/edit$/, permission: { operationKey: "fees_contracts:update" } },
{ pattern: /^\/finance\/contracts\/[^/]+\/edit$/, permission: { operationKey: "finance_contracts:update" } },
{ pattern: /^\/drug\/shipments\/[^/]+\/edit$/, permission: { operationKey: "drug_shipments:update" } },
{ pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "ethics:update" } },
{ pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "ethics:update" } },
{ pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "startup_initiation:update" } },
{ pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "startup_ethics:update" } },
{ pattern: /^\/startup\/kickoff\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
{ pattern: /^\/startup\/training\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
{ pattern: /^\/subjects\/[^/]+\/edit$/, permission: { operationKey: "subjects:update" } },
{ pattern: /^\/knowledge\/notes\/[^/]+\/edit$/, permission: { operationKey: "knowledge_notes:update" } },
{ pattern: /^\/knowledge\/precautions\/[^/]+\/edit$/, permission: { operationKey: "precautions:update" } },
];
export const projectRouteLandingPaths = [
@@ -53,10 +53,9 @@ export const projectRouteLandingPaths = [
"/startup/meeting-auth",
"/subjects",
"/risk-issues/sae",
"/monitoring-audit",
"/etmf",
"/knowledge/medical-consult",
"/knowledge/notes",
"/knowledge/precautions",
"/knowledge/support-files",
"/knowledge/instruction-files",
];