From f0cbee11dcba5ae67a64b59f05f0bf85c140d4fb Mon Sep 17 00:00:00 2001 From: dolphin Date: Mon, 24 Aug 2026 23:23:04 +0800 Subject: [PATCH] fix(platform): keep route permissions intact across router rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a board and clicking 编辑 landed on /404 while the board list itself stayed reachable — the two routes disagreed about the same `board` permission. The route filter wrote its result back into the shared `privateRouter` (`cur.children = filterMenuItem(cur.children)`), and those children had already had their `permission` key destructured away. The router is rebuilt whenever `user` changes, so from the second build on nested routes carried no permission to check and survived unconditionally, while top-level routes — the board editor among them — were still filtered. A user without `board` therefore kept the list page and lost its editor. The filter now builds a fresh tree and never touches the shared table, so both routes make the same decision. The regression test fails on the old code with exactly the reported asymmetry: `dashboard` retained, `dashboard/:id` dropped. --- src/frontend/platform/src/routes/index.tsx | 25 ++++++------ .../src/test/routeFilterPurity.test.ts | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 src/frontend/platform/src/test/routeFilterPurity.test.ts diff --git a/src/frontend/platform/src/routes/index.tsx b/src/frontend/platform/src/routes/index.tsx index 924f1f897..db8d97bd4 100755 --- a/src/frontend/platform/src/routes/index.tsx +++ b/src/frontend/platform/src/routes/index.tsx @@ -205,28 +205,29 @@ export const getPrivateRouter = ( permissions: string[], opts?: { menuApprovalMode?: boolean }, ) => { - const filterMenuItem = (_privateRouter) => { - const result = _privateRouter.reduce((res, cur) => { - // 递归 - if (cur.children?.length) { - cur.children = filterMenuItem(cur.children) - } + // Builds a fresh route tree; `privateRouter` itself is never touched. It used + // to write the filtered children back into the shared table, and those copies + // had already lost their `permission` key — so from the second build on (the + // router is rebuilt whenever `user` changes) nested routes were no longer + // filtered at all, while top-level ones still were. That asymmetry let a user + // reach a list page they should not see and then hit /404 on its top-level + // editor route. + const filterMenuItem = (routes) => + routes.reduce((res, cur) => { + const { permission, children, ...other } = cur + const next = children?.length ? { ...other, children: filterMenuItem(children) } : { ...other } - const { permission, ...other } = cur if (permission && !hasRoutePermission(permissions, permission)) { if (opts?.menuApprovalMode) { - res.push({ ...other, element: }) + res.push({ ...next, element: }) } return res } - res.push(other) + res.push(next) return res }, []) - return result - } - return createBrowserRouter(permissions ? filterMenuItem(privateRouter) : [], baseConfig) } diff --git a/src/frontend/platform/src/test/routeFilterPurity.test.ts b/src/frontend/platform/src/test/routeFilterPurity.test.ts new file mode 100644 index 000000000..fe01e7ecf --- /dev/null +++ b/src/frontend/platform/src/test/routeFilterPurity.test.ts @@ -0,0 +1,38 @@ +/** + * The route filter must not touch the shared route table. + * + * It used to write filtered children back into `privateRouter`, and those copies + * had already lost their `permission` key — so every build after the first left + * nested routes unfiltered while top-level ones were still filtered. A user then + * reached a list page they should not see and hit /404 on its editor route. + */ +import { describe, expect, it } from "vitest" + +import { getPrivateRouter } from "@/routes" + +type RouteNode = { path?: string; children?: RouteNode[] } + +const pathsOf = (routes: RouteNode[]): string[] => + routes.flatMap((route) => [route.path, ...(route.children ? pathsOf(route.children) : [])]).filter(Boolean) + +describe("getPrivateRouter", () => { + it("filters the same way however many times it is called", () => { + const withBoard = pathsOf(getPrivateRouter(["board"]).routes as RouteNode[]) + expect(withBoard).toContain("dashboard") + expect(withBoard).toContain("dashboard/:id") + + const withoutBoard = pathsOf(getPrivateRouter([]).routes as RouteNode[]) + expect(withoutBoard).not.toContain("dashboard") + expect(withoutBoard).not.toContain("dashboard/:id") + + const withBoardAgain = pathsOf(getPrivateRouter(["board"]).routes as RouteNode[]) + expect(withBoardAgain).toEqual(withBoard) + }) + + it("keeps a denied route as a placeholder in menu-approval mode", () => { + const paths = pathsOf(getPrivateRouter([], { menuApprovalMode: true }).routes as RouteNode[]) + + expect(paths).toContain("dashboard") + expect(paths).toContain("dashboard/:id") + }) +})