import { type Route } from "@/src/components/layouts/routes";
import { type PropsWithChildren, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { getSession, signOut, useSession } from "next-auth/react";
import Head from "next/head";
import { env } from "@/src/env.mjs";
import { Spinner } from "@/src/components/layouts/spinner";
import { hasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { Toaster } from "@/src/components/ui/sonner";
import DOMPurify from "dompurify";
import { ThemeToggle } from "@/src/features/theming/ThemeToggle";
import { useQueryProjectOrOrganization } from "@/src/features/projects/hooks";
import { useEntitlements } from "@/src/features/entitlements/hooks";
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
import { hasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
import { SidebarInset, SidebarProvider } from "@/src/components/ui/sidebar";
import { AppSidebar } from "@/src/components/nav/app-sidebar";
import { CommandMenu } from "@/src/features/command-k-menu/CommandMenu";
import { SupportDrawer } from "@/src/features/support-chat/SupportDrawer";
import { useSupportDrawer } from "@/src/features/support-chat/SupportDrawerProvider";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/src/components/ui/resizable";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/src/components/ui/drawer";
import { useMediaQuery } from "react-responsive";
import {
processNavigation,
type NavigationItem,
} from "@/src/components/layouts/utilities/routes";
const signOutUser = async () => {
sessionStorage.clear();
await signOut();
};
const getUserNavigation = () => {
return [
{
name: "Theme",
onClick: () => {},
content: ,
},
{
name: "Sign out",
onClick: signOutUser,
},
];
};
const pathsWithoutNavigation: string[] = [
"/onboarding",
"/auth/reset-password",
];
const unauthenticatedPaths: string[] = [
"/auth/sign-in",
"/auth/sign-up",
"/auth/error",
"/auth/hf-spaces",
];
// auth or unauthed
const publishablePaths: string[] = [
"/project/[projectId]/sessions/[sessionId]",
"/project/[projectId]/traces/[traceId]",
"/auth/reset-password",
];
/**
* Patched version of useSession that retries fetching the session if the user
* is unauthenticated. This is useful to mitigate exceptions on the
* /api/auth/session endpoint which cause the session to be unauthenticated even
* though the user is signed in.
*/
function useSessionWithRetryOnUnauthenticated() {
const MAX_RETRIES = 2;
const [retryCount, setRetryCount] = useState(0);
const session = useSession();
useEffect(() => {
if (session.status === "unauthenticated" && retryCount < MAX_RETRIES) {
const fetchSession = async () => {
try {
await getSession({ broadcast: true });
} catch (error) {
console.error(
"Error fetching session:",
error,
"\nError details:",
JSON.stringify(error, null, 2),
);
throw error;
}
setRetryCount((prevCount) => prevCount + 1);
};
fetchSession();
}
if (session.status === "authenticated" && retryCount > 0) {
setRetryCount(0);
}
}, [session.status, retryCount]);
return session.status !== "unauthenticated" || retryCount >= MAX_RETRIES
? session
: { ...session, status: "loading" };
}
export default function Layout(props: PropsWithChildren) {
const router = useRouter();
const routerProjectId = router.query.projectId as string | undefined;
const routerOrganizationId = router.query.organizationId as
| string
| undefined;
const session = useSessionWithRetryOnUnauthenticated();
const enableExperimentalFeatures =
session.data?.environment.enableExperimentalFeatures ?? false;
const entitlements = useEntitlements();
const uiCustomization = useUiCustomization();
const cloudAdmin =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
session.data?.user?.admin === true;
// project info based on projectId in the URL
const { project, organization } = useQueryProjectOrOrganization();
// Helper function for precise path matching
const isPathActive = (routePath: string, currentPath: string): boolean => {
// Exact match
if (currentPath === routePath) return true;
// Only allow prefix matching if the route ends with a specific page (not just project root)
// This prevents /project/123 from matching /project/123/datasets
const isRoot = routePath.split("/").length <= 3;
if (isRoot) return false;
return currentPath.startsWith(routePath + "/");
};
const mapNavigation = (route: Route): NavigationItem | null => {
// Project-level routes
if (!routerProjectId && route.pathname.includes("[projectId]")) return null;
// Organization-level routes
if (!routerOrganizationId && route.pathname.includes("[organizationId]"))
return null;
// UI customization – hide routes that belong to a disabled product module
if (
route.productModule &&
uiCustomization !== null &&
!uiCustomization.visibleModules.includes(route.productModule)
)
return null;
// Feature Flags
if (
route.featureFlag !== undefined &&
!enableExperimentalFeatures &&
!cloudAdmin &&
session.data?.user?.featureFlags[route.featureFlag] !== true
)
return null;
// check entitlements
if (
route.entitlements !== undefined &&
!route.entitlements.some((entitlement) =>
entitlements.includes(entitlement),
) &&
!cloudAdmin
)
return null;
// RBAC
if (
route.projectRbacScopes !== undefined &&
!cloudAdmin &&
(!project ||
!organization ||
!route.projectRbacScopes.some((scope) =>
hasProjectAccess({
projectId: project.id,
scope,
session: session.data,
}),
))
)
return null;
if (
route.organizationRbacScope !== undefined &&
!cloudAdmin &&
(!organization ||
!hasOrganizationAccess({
organizationId: organization.id,
scope: route.organizationRbacScope,
session: session.data,
}))
)
return null;
// check show function
if (route.show && !route.show({ organization: organization ?? undefined }))
return null;
// apply to children as well
const items: (NavigationItem | null)[] =
route.items?.map((item) => mapNavigation(item)).filter(Boolean) ?? [];
const url = route.pathname
?.replace("[projectId]", routerProjectId ?? "")
.replace("[organizationId]", routerOrganizationId ?? "");
return {
...route,
url: url,
isActive: isPathActive(route.pathname, router.pathname),
items:
items.length > 0
? (items as NavigationItem[]) // does not include null due to filter
: undefined,
};
};
// Process navigation using the dedicated utility
const { mainNavigation, secondaryNavigation, navigation } =
processNavigation(mapNavigation);
const activePathName = navigation.find((item) => item.isActive)?.title;
if (session.status === "loading") return ;
// If the user has a token, but does not exist in the database, sign them out
if (
session.data &&
session.data.user === null &&
!unauthenticatedPaths.includes(router.pathname) &&
!publishablePaths.includes(router.pathname) &&
!router.pathname.startsWith("/public/")
) {
console.warn("Layout: User was signed out as db user was not found");
signOutUser();
return ;
}
if (
session.status === "unauthenticated" &&
!unauthenticatedPaths.includes(router.pathname) &&
!publishablePaths.includes(router.pathname) &&
!router.pathname.startsWith("/public/")
) {
const newTargetPath = router.asPath;
if (newTargetPath && newTargetPath !== "/") {
void router.replace(
`/auth/sign-in?targetPath=${encodeURIComponent(newTargetPath)}`,
);
} else {
void router.replace(`/auth/sign-in`);
}
return ;
}
if (
session.status === "authenticated" &&
unauthenticatedPaths.includes(router.pathname)
) {
const queryTargetPath = router.query.targetPath as string | undefined;
const sanitizedTargetPath = queryTargetPath
? DOMPurify.sanitize(queryTargetPath)
: undefined;
// only allow relative links
const targetPath =
sanitizedTargetPath?.startsWith("/") &&
!sanitizedTargetPath.startsWith("//")
? sanitizedTargetPath
: "/";
void router.replace(targetPath);
return ;
}
const hideNavigation =
session.status === "unauthenticated" ||
pathsWithoutNavigation.includes(router.pathname) ||
router.pathname.startsWith("/public/");
if (hideNavigation)
return (
{props.children}
);
return (
<>
{activePathName ? `${activePathName} | Langfuse` : "Langfuse"}
>
);
}
/** Resizable content for support drawer on the right side of the screen (desktop).
* On mobile, renders a Drawer instead of a resizable sidebar.
*/
export function ResizableContent({ children }: PropsWithChildren) {
const { open, setOpen } = useSupportDrawer();
const isDesktop = useMediaQuery({ query: "(min-width: 768px)" });
// Keep cookie-based layout only for desktop
const COOKIE_KEY = "react-resizable-panels:layout:supportDrawer";
const [mounted, setMounted] = useState(false);
const [defaultLayout, setDefaultLayout] = useState(
undefined,
);
useEffect(() => {
setMounted(true);
if (!isDesktop /* || !open */) return; // no layout restore needed on mobile (and you can also gate on open if you want)
try {
if (typeof document !== "undefined") {
const match = document.cookie.match(
new RegExp(
"(?:^|; )" +
COOKIE_KEY.replace(/([.$?*|{}()\[\]\\\/\+^])/g, "\\$1") +
"=([^;]*)",
),
);
if (match?.[1]) {
const parsed = JSON.parse(decodeURIComponent(match[1]));
if (Array.isArray(parsed) && parsed.length === 2) {
setDefaultLayout(parsed as number[]);
}
}
}
} catch {
// ignore cookie parse errors
}
}, [isDesktop /*, open */]);
const onLayout = (sizes: number[]) => {
if (!isDesktop) return;
try {
document.cookie = `${COOKIE_KEY}=${encodeURIComponent(
JSON.stringify(sizes),
)}; path=/; max-age=${60 * 60 * 24 * 365}`;
} catch {
// ignore cookie write errors
}
};
// MOBILE: main + overlay drawer
if (!isDesktop) {
return (
<>
{children}
{/* sr-only for screen readers and accessibility */}
Support
A list of resources and options to help you with your questions.
>
);
}
// 👉 DESKTOP: if drawer isn't open, render only the main content (like before)
if (isDesktop && !open) {
return {children};
}
if (!mounted) {
return {children};
}
const mainDefault = defaultLayout?.[0] ?? 70;
const drawerDefault = defaultLayout?.[1] ?? 30;
return (
{children}
);
}