Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/config/extraNavItems.test.ts
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>" },
{ 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/" }]);
});
});
92 changes: 92 additions & 0 deletions src/config/extraNavItems.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
2 changes: 2 additions & 0 deletions src/routes/Dashboard/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -154,6 +155,7 @@ export function DashboardLayout() {
</InlineStack>
)}
</Link>
<ExtraNavItems className={cn("w-full", navItemClass(false))} />
{requiresAuthorization && (
<div className="px-3 py-2">
<TopBarAuthentication />
Expand Down
83 changes: 83 additions & 0 deletions src/routes/Dashboard/ExtraNavItems.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<ExtraNavItems />
</QueryClientProvider>,
);
}

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();
});
});
44 changes: 44 additions & 0 deletions src/routes/Dashboard/ExtraNavItems.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<UILink
key={`${item.href}:${item.label}`}
href={item.href}
external={item.external}
variant="block"
size="sm"
className={className}
>
<InlineStack gap="2" blockAlign="center" className="flex-1">
{item.icon && <Icon name={item.icon} size="sm" />}
<Text size="sm">{item.label}</Text>
</InlineStack>
</UILink>
))}
</>
);
}
Loading