diff --git a/src/config/extraNavItems.test.ts b/src/config/extraNavItems.test.ts new file mode 100644 index 000000000..353032d83 --- /dev/null +++ b/src/config/extraNavItems.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { getExtraNavItems } from "./extraNavItems"; + +describe("extraNavItems", () => { + afterEach(() => { + delete window.__TANGLE_EXTRA_NAV_ITEMS__; + }); + + it("returns nothing when the host page injects no items", () => { + expect(getExtraNavItems(["admin"])).toEqual([]); + }); + + it("returns items that require no permission", () => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { label: "Runbook", href: "https://example.com/runbook", external: true }, + ]; + + expect(getExtraNavItems([])).toEqual([ + { label: "Runbook", href: "https://example.com/runbook", external: true }, + ]); + }); + + it("hides items whose required permission the user lacks", () => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { + label: "Admin", + href: "/admin/", + icon: "ShieldCheck", + requiresPermission: "admin", + }, + ]; + + expect(getExtraNavItems(["read", "write"])).toEqual([]); + expect(getExtraNavItems(["read", "write", "admin"])).toEqual([ + { + label: "Admin", + href: "/admin/", + icon: "ShieldCheck", + requiresPermission: "admin", + }, + ]); + }); + + it("drops items without a usable label or href", () => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { label: " ", href: "/admin/" }, + { label: "No href" } as never, + "not an object" as never, + ]; + + expect(getExtraNavItems([])).toEqual([]); + }); + + it("rejects hrefs that are neither same-origin paths nor http(s) URLs", () => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { label: "Script", href: "javascript:alert(1)" }, + { label: "Data", href: "data:text/html," }, + { label: "Relative", href: "admin/" }, + ]; + + expect(getExtraNavItems([])).toEqual([]); + }); + + it("rejects leading-slash hrefs that resolve off-origin", () => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { label: "Protocol relative", href: "//evil.com" }, + { label: "Backslash authority", href: "/\\evil.com" }, + ]; + + expect(getExtraNavItems([])).toEqual([]); + }); + + it("keeps the path, query and hash of a same-origin href", () => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { label: "Admin", href: "/admin/?tab=users#top" }, + ]; + + expect(getExtraNavItems([])).toEqual([ + { label: "Admin", href: "/admin/?tab=users#top" }, + ]); + }); + + it("keeps an item but drops an icon name the icon set does not have", () => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { label: "Admin", href: "/admin/", icon: "NotARealIcon" as never }, + ]; + + expect(getExtraNavItems([])).toEqual([{ label: "Admin", href: "/admin/" }]); + }); +}); diff --git a/src/config/extraNavItems.ts b/src/config/extraNavItems.ts new file mode 100644 index 000000000..17b35e411 --- /dev/null +++ b/src/config/extraNavItems.ts @@ -0,0 +1,92 @@ +import { icons } from "lucide-react"; + +import type { IconName } from "@/components/ui/icon"; +import { isRecord } from "@/utils/typeGuards"; + +export interface ExtraNavItem { + label: string; + href: string; + icon?: IconName; + requiresPermission?: string; + external?: boolean; +} + +declare global { + interface Window { + __TANGLE_EXTRA_NAV_ITEMS__?: ExtraNavItem[]; + } +} + +function readIconName(value: unknown): IconName | undefined { + if (typeof value !== "string" || !Object.hasOwn(icons, value)) { + return undefined; + } + return value as IconName; +} + +/** + * Only same-origin paths and http(s) URLs are accepted, so a host page that + * assembles this config from somewhere less trusted than its own markup cannot + * turn a nav item into a `javascript:` sink. + * + * A leading slash is not enough to call a path same-origin: `//evil.com` and its + * backslash variants resolve off-origin, so the path branch resolves against the + * current origin and keeps only what still belongs to it. + */ +function readHref(value: unknown): string | null { + if (typeof value !== "string") return null; + + const href = value.trim(); + if (href.startsWith("/")) { + try { + const url = new URL(href, window.location.origin); + return url.origin === window.location.origin + ? url.pathname + url.search + url.hash + : null; + } catch { + return null; + } + } + + return /^https?:\/\//i.test(href) ? href : null; +} + +function readTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return value.trim() || undefined; +} + +function readNavItem(value: unknown): ExtraNavItem | null { + if (!isRecord(value)) return null; + + const href = readHref(value.href); + const label = readTrimmedString(value.label); + if (!href || !label) return null; + + const icon = readIconName(value.icon); + const requiresPermission = readTrimmedString(value.requiresPermission); + + return { + label, + href, + ...(icon ? { icon } : {}), + ...(requiresPermission ? { requiresPermission } : {}), + ...(value.external === true ? { external: true } : {}), + }; +} + +export function getExtraNavItems(permissions: string[]): ExtraNavItem[] { + if (typeof window === "undefined") return []; + + const injected = window.__TANGLE_EXTRA_NAV_ITEMS__; + if (!Array.isArray(injected)) return []; + + return injected + .map(readNavItem) + .filter((item) => item !== null) + .filter( + (item) => + !item.requiresPermission || + permissions.includes(item.requiresPermission), + ); +} diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx index 208db0124..bc5557818 100644 --- a/src/routes/Dashboard/DashboardLayout.tsx +++ b/src/routes/Dashboard/DashboardLayout.tsx @@ -11,6 +11,7 @@ import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; import { APP_ROUTES } from "@/routes/appRoutes"; +import { ExtraNavItems } from "@/routes/Dashboard/ExtraNavItems"; import { ABOUT_URL, DOCUMENTATION_URL, @@ -154,6 +155,7 @@ export function DashboardLayout() { )} + {requiresAuthorization && (
diff --git a/src/routes/Dashboard/ExtraNavItems.test.tsx b/src/routes/Dashboard/ExtraNavItems.test.tsx new file mode 100644 index 000000000..866516337 --- /dev/null +++ b/src/routes/Dashboard/ExtraNavItems.test.tsx @@ -0,0 +1,83 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getUserDetails } from "@/utils/user"; + +import { ExtraNavItems } from "./ExtraNavItems"; + +vi.mock("@/utils/user", () => ({ + getUserDetails: vi.fn(), +})); + +function renderWithQueryClient() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + , + ); +} + +function mockPermissions(permissions: string[]) { + vi.mocked(getUserDetails).mockResolvedValue({ + id: "someone@example.com", + permissions, + }); +} + +describe("ExtraNavItems", () => { + beforeEach(() => { + window.__TANGLE_EXTRA_NAV_ITEMS__ = [ + { + label: "Admin", + href: "/admin/", + icon: "ShieldCheck", + requiresPermission: "admin", + }, + ]; + }); + + afterEach(() => { + delete window.__TANGLE_EXTRA_NAV_ITEMS__; + vi.resetAllMocks(); + }); + + it("links to an injected item once the user's permissions arrive", async () => { + mockPermissions(["read", "write", "admin"]); + + renderWithQueryClient(); + + await waitFor(() => { + expect(screen.getByRole("link", { name: "Admin" })).toHaveAttribute( + "href", + "/admin/", + ); + }); + }); + + it("does not open same-origin items in a new tab", async () => { + mockPermissions(["admin"]); + + renderWithQueryClient(); + + await waitFor(() => { + expect(screen.getByRole("link", { name: "Admin" })).not.toHaveAttribute( + "target", + ); + }); + }); + + it("renders nothing for a user without the required permission", async () => { + mockPermissions(["read", "write"]); + + renderWithQueryClient(); + + await waitFor(() => expect(getUserDetails).toHaveBeenCalled()); + + expect(screen.queryByRole("link")).toBeNull(); + }); +}); diff --git a/src/routes/Dashboard/ExtraNavItems.tsx b/src/routes/Dashboard/ExtraNavItems.tsx new file mode 100644 index 000000000..68d097468 --- /dev/null +++ b/src/routes/Dashboard/ExtraNavItems.tsx @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; + +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Link as UILink } from "@/components/ui/link"; +import { Text } from "@/components/ui/typography"; +import { getExtraNavItems } from "@/config/extraNavItems"; +import { userQueryOptions } from "@/hooks/useUserDetails"; + +interface ExtraNavItemsProps { + className?: string; +} + +/** + * Renders the nav items a host page injects via `window.__TANGLE_EXTRA_NAV_ITEMS__`. + * + * Items carrying `requiresPermission` are hidden from users without it. That is + * presentation only — whatever they link to must enforce its own access control. + */ +export function ExtraNavItems({ className }: ExtraNavItemsProps) { + const { data: user } = useQuery(userQueryOptions); + + const items = getExtraNavItems(user?.permissions ?? []); + + return ( + <> + {items.map((item) => ( + + + {item.icon && } + {item.label} + + + ))} + + ); +}