fix(platform): keep route permissions intact across router rebuilds

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.
This commit is contained in:
dolphin
2026-08-24 23:23:04 +08:00
parent 5cb1b9dc23
commit f0cbee11dc
2 changed files with 51 additions and 12 deletions
+13 -12
View File
@@ -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: <MenuPermissionPlaceholder /> })
res.push({ ...next, element: <MenuPermissionPlaceholder /> })
}
return res
}
res.push(other)
res.push(next)
return res
}, [])
return result
}
return createBrowserRouter(permissions ? filterMenuItem(privateRouter) : [],
baseConfig)
}
@@ -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")
})
})