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