diff --git a/frontend/src/composables/useRoleTemplateMeta.test.ts b/frontend/src/composables/useRoleTemplateMeta.test.ts index c0373438..b2587780 100644 --- a/frontend/src/composables/useRoleTemplateMeta.test.ts +++ b/frontend/src/composables/useRoleTemplateMeta.test.ts @@ -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", ]); }); }); diff --git a/frontend/src/composables/useRoleTemplateMeta.ts b/frontend/src/composables/useRoleTemplateMeta.ts index 2c147bc6..60312cc7 100644 --- a/frontend/src/composables/useRoleTemplateMeta.ts +++ b/frontend/src/composables/useRoleTemplateMeta.ts @@ -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 = { 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; diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index 3d176654..e24d89c3 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -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)"); }); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c41073b7..fb4ce0a3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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, +) => { + 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) { diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts index 671b7852..72a54f0a 100644 --- a/frontend/src/views/Login.test.ts +++ b/frontend/src/views/Login.test.ts @@ -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"); + }); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 7707a90f..de73eb34 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -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 { diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index ffbddeb1..88475dfe 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -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(); diff --git a/frontend/src/views/admin/Projects.vue b/frontend/src/views/admin/Projects.vue index e805fbe8..05d69bc7 100644 --- a/frontend/src/views/admin/Projects.vue +++ b/frontend/src/views/admin/Projects.vue @@ -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]); };