修正项目访问与角色排序体验

- 登录成功后按用户恢复上次项目,管理员优先选择启用项目\n- 项目详情路由增加 setup_config:read 权限校验,未授权时回到可访问页面\n- 项目管理操作按当前项目行角色读取权限,避免误用首个角色权限\n- 角色模板排序中固定 PM 优先,保持角色列表、生效管理和权限编辑器顺序一致\n- 补充登录、路由、项目管理和角色排序相关测试
This commit is contained in:
Cheng Zhou
2026-06-04 16:31:56 +08:00
parent 0b3a4d4d31
commit e78bb04c4b
8 changed files with 60 additions and 17 deletions
@@ -6,11 +6,11 @@ import { useRoleTemplateMeta } from "./useRoleTemplateMeta";
vi.mock("@/api/projectPermissions", () => ({
fetchPermissionTemplates: vi.fn().mockResolvedValue({
data: [
{ category: "PM", name: "PM" },
{ category: "CRA", name: "CRA" },
{ category: "CTA", name: "CTA" },
{ category: "QA", name: "QA" },
{ category: "PV", name: "PV" },
{ category: "QA", name: "QA" },
{ category: "CTA", name: "CTA" },
{ category: "CRA", name: "CRA" },
{ category: "PM", name: "PM" },
],
}),
}));
@@ -31,16 +31,16 @@ describe("useRoleTemplateMeta fallback copy", () => {
expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。");
});
it("sorts roles by the role template list order", async () => {
it("pins PM first before applying the role template list order", async () => {
const { compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
await loadRoleTemplates();
expect(["CRA", "PV", "QA", "PM", "CTA"].sort(compareRolesByTemplateOrder)).toEqual([
"PM",
"CRA",
"CTA",
"QA",
"PV",
"QA",
"CTA",
"CRA",
]);
});
});
@@ -1,5 +1,6 @@
import { computed, ref } from "vue";
import { fetchPermissionTemplates, type PermissionTemplate } from "@/api/projectPermissions";
import { compareProjectPermissionRoles } from "@/utils/projectPermissionRoles";
const FALLBACK_ROLE_META: Record<string, { label: string; icon: string; desc: string }> = {
ADMIN: { label: "管理员", icon: "🛡️", desc: "系统管理员" },
@@ -58,6 +59,7 @@ export const useRoleTemplateMeta = () => {
});
const compareRolesByTemplateOrder = (a: string, b: string) => {
if (a === "PM" || b === "PM") return compareProjectPermissionRoles(a, b);
const order = roleOrder.value;
const aOrder = order[a] ?? Number.MAX_SAFE_INTEGER;
const bOrder = order[b] ?? Number.MAX_SAFE_INTEGER;
+3
View File
@@ -10,6 +10,9 @@ describe("admin project route permissions", () => {
expect(source).toContain('name: "AdminProjectDetail"');
expect(source).toContain("requiresProjectMember: true");
expect(source).toContain('projectPermission: "setup_config:read"');
expect(source).toContain("to.meta.projectPermission");
expect(source).toContain("ensureProjectPermissionAccess(to.meta.projectPermission as string, isAdmin, studyStore)");
expect(source).not.toContain("meta: { title: TEXT.modules.adminProjects.detailTitle, requiresAdmin: true }");
expect(source).toContain("ensureRouteProjectMember(to, studyStore)");
});
+22 -1
View File
@@ -350,7 +350,7 @@ const routes: RouteRecordRaw[] = [
path: "projects/:projectId",
name: "AdminProjectDetail",
component: ProjectDetail,
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresProjectMember: true },
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresProjectMember: true, projectPermission: "setup_config:read" },
},
{
path: "projects/:projectId/members",
@@ -466,6 +466,20 @@ const ensureRouteProjectMember = async (to: any, studyStore: ReturnType<typeof u
return true;
};
const ensureProjectPermissionAccess = async (
operationKey: string,
isAdmin: boolean,
studyStore: ReturnType<typeof useStudyStore>,
) => {
if (isAdmin) return true;
if (!operationKey || !studyStore.currentStudy) return false;
if (!studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions();
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
};
router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -533,6 +547,13 @@ router.beforeEach(async (to, _from, next) => {
return;
}
}
if (to.meta.projectPermission) {
const allowed = await ensureProjectPermissionAccess(to.meta.projectPermission as string, isAdmin, studyStore).catch(() => false);
if (!allowed) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
}
if (to.meta.systemPermission) {
const allowed = await hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin);
if (!allowed) {
+13
View File
@@ -61,4 +61,17 @@ describe("Login protocol agreement", () => {
expect(source).toContain(":autocomplete=\"form.rememberPassword ? 'current-password' : 'new-password'\"");
expect(source).not.toContain('autocomplete="current-password"');
});
it("restores the current user's last project before routing after login", () => {
const source = readLoginView();
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
const userKeyIndex = source.indexOf("const userKey = auth.user?.email || form.email");
const restoreIndex = source.indexOf("studyStore.restoreStudyForUser(userKey");
const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")');
expect(userKeyIndex).toBeGreaterThan(loginCallIndex);
expect(restoreIndex).toBeGreaterThan(loginCallIndex);
expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex);
expect(source).toContain("preferActive: !!auth.user?.is_admin");
});
});
+2 -7
View File
@@ -239,13 +239,8 @@ const onSubmit = async () => {
await auth.login(form.email, form.password);
await tryStoreBrowserCredential(form.email, form.password);
const studyStore = useStudyStore();
if (!studyStore.currentStudy) {
if (auth.user?.is_admin) {
await studyStore.ensureDefaultActiveStudy();
} else {
await studyStore.ensureDefaultStudy();
}
}
const userKey = auth.user?.email || form.email;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
if (studyStore.currentStudy) {
router.push("/project/overview");
} else {
@@ -83,6 +83,14 @@ describe("project management access", () => {
expect(source).toContain('if (!canProject(row, "setup_config", "read"))');
});
it("checks project management actions against the current row role instead of the first permission role", () => {
const source = readProjects();
expect(source).toContain("const role = row.role_in_study || \"\";");
expect(source).toContain("const rolePerms = perms[role];");
expect(source).not.toContain("Object.values(perms)[0]");
});
it("keeps audit export write mappings on the export operation", () => {
const projects = readProjects();
const router = readRouter();
+2 -1
View File
@@ -226,7 +226,8 @@ const canProject = (row: Study, module: string, action: "read" | "write") => {
if (!operationKey) return false;
const perms = permissionsByProject.value[row.id];
if (!perms) return false;
const rolePerms = Object.values(perms)[0];
const role = row.role_in_study || "";
const rolePerms = perms[role];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms[operationKey]);
};