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"}
{props.children}
); } /** 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}
); }