diff --git a/ui/litellm-dashboard/.env.development b/ui/litellm-dashboard/.env.development new file mode 100644 index 000000000000..b408125408b3 --- /dev/null +++ b/ui/litellm-dashboard/.env.development @@ -0,0 +1,2 @@ +NODE_ENV=development +NEXT_PUBLIC_BASE_URL="" \ No newline at end of file diff --git a/ui/litellm-dashboard/.env.production b/ui/litellm-dashboard/.env.production new file mode 100644 index 000000000000..1df897e7d23c --- /dev/null +++ b/ui/litellm-dashboard/.env.production @@ -0,0 +1,2 @@ +NODE_ENV=production +NEXT_PUBLIC_BASE_URL="ui/" \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx new file mode 100644 index 000000000000..265a6d81954e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import APIRef from "@/components/api_ref"; +import { useState } from "react"; + +interface ProxySettings { + PROXY_BASE_URL: string; + PROXY_LOGOUT_URL: string; +} + +const APIReferencePage = () => { + const [proxySettings, setProxySettings] = useState({ PROXY_BASE_URL: "", PROXY_LOGOUT_URL: "" }); + + return ; +}; + +export default APIReferencePage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx new file mode 100644 index 000000000000..96bb9b5a4bf7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { Layout, Menu, ConfigProvider } from "antd"; +import { + KeyOutlined, + PlayCircleOutlined, + BlockOutlined, + BarChartOutlined, + TeamOutlined, + BankOutlined, + UserOutlined, + SettingOutlined, + ApiOutlined, + AppstoreOutlined, + DatabaseOutlined, + FileTextOutlined, + LineChartOutlined, + SafetyOutlined, + ExperimentOutlined, + ToolOutlined, + TagsOutlined, +} from "@ant-design/icons"; +// import { +// all_admin_roles, +// rolesWithWriteAccess, +// internalUserRoles, +// isAdminRole, +// } from "../utils/roles"; +// import UsageIndicator from "./usage_indicator"; +import * as React from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles"; +import UsageIndicator from "@/components/usage_indicator"; + +const { Sider } = Layout; + +// -------- Types -------- +interface SidebarProps { + accessToken: string | null; + userRole: string; + /** Fallback selection id (legacy), used if path can't be matched */ + defaultSelectedKey: string; + collapsed?: boolean; +} + +interface MenuItemCfg { + key: string; + page: string; // legacy id; we map this to a path below + label: string; + roles?: string[]; + children?: MenuItemCfg[]; + icon?: React.ReactNode; +} + +/** ---------- Base URL helpers ---------- */ +/** + * Normalizes NEXT_PUBLIC_BASE_URL to either "/" or "/ui/" (always with a trailing slash). + * Supported env values: "" or "ui/". + */ +const getBasePath = () => { + const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; + const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes + return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash +}; + +/** Map legacy `page` ids to real app routes (relative, no leading slash). */ +const routeFor = (slug: string): string => { + switch (slug) { + // top level + case "api-keys": + return "virtual-keys"; + case "llm-playground": + return "test-key"; + case "models": + return "models-and-endpoints"; + case "new_usage": + return "usage"; + case "teams": + return "teams"; + case "organizations": + return "organizations"; + case "users": + return "users"; + case "api_ref": + return "api-reference"; + case "model-hub-table": + // If you intend the newer in-dashboard page, use "model-hub". + return "model-hub"; + case "logs": + return "logs"; + case "guardrails": + return "guardrails"; + + // tools + case "mcp-servers": + return "tools/mcp-servers"; + case "vector-stores": + return "tools/vector-stores"; + + // experimental + case "caching": + return "experimental/caching"; + case "prompts": + return "experimental/prompts"; + case "budgets": + return "experimental/budgets"; + case "transform-request": + return "experimental/api-playground"; + case "tag-management": + return "experimental/tag-management"; + case "usage": // "Old Usage" + return "experimental/old-usage"; + + // settings + case "general-settings": + return "settings/router-settings"; + case "settings": // "Logging & Alerts" + return "settings/logging-and-alerts"; + case "admin-panel": + return "settings/admin-settings"; + case "ui-theme": + return "settings/ui-theme"; + + default: + // treat as already a relative path + return slug.replace(/^\/+/, ""); + } +}; + +/** Prefix base path ("/" or "/ui/") */ +const toHref = (slugOrPath: string) => { + const base = getBasePath(); // "/" or "/ui/" + const rel = routeFor(slugOrPath).replace(/^\/+|\/+$/g, ""); + return `${base}${rel}`; +}; + +const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { + const router = useRouter(); + const pathname = usePathname() || "/"; + + // ----- Menu config (unchanged labels/icons; same appearance) ----- + const menuItems: MenuItemCfg[] = [ + { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, + { + key: "3", + page: "llm-playground", + label: "Test Key", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "2", + page: "models", + label: "Models + Endpoints", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "12", + page: "new_usage", + label: "Usage", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { key: "6", page: "teams", label: "Teams", icon: }, + { + key: "17", + page: "organizations", + label: "Organizations", + icon: , + roles: all_admin_roles, + }, + { + key: "5", + page: "users", + label: "Internal Users", + icon: , + roles: all_admin_roles, + }, + { key: "14", page: "api_ref", label: "API Reference", icon: }, + { + key: "16", + page: "model-hub-table", + label: "Model Hub", + icon: , + }, + { key: "15", page: "logs", label: "Logs", icon: }, + { + key: "11", + page: "guardrails", + label: "Guardrails", + icon: , + roles: all_admin_roles, + }, + { + key: "26", + page: "tools", + label: "Tools", + icon: , + children: [ + { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, + { + key: "21", + page: "vector-stores", + label: "Vector Stores", + icon: , + roles: all_admin_roles, + }, + ], + }, + { + key: "experimental", + page: "experimental", + label: "Experimental", + icon: , + children: [ + { + key: "9", + page: "caching", + label: "Caching", + icon: , + roles: all_admin_roles, + }, + { + key: "25", + page: "prompts", + label: "Prompts", + icon: , + roles: all_admin_roles, + }, + { + key: "10", + page: "budgets", + label: "Budgets", + icon: , + roles: all_admin_roles, + }, + { + key: "20", + page: "transform-request", + label: "API Playground", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { + key: "19", + page: "tag-management", + label: "Tag Management", + icon: , + roles: all_admin_roles, + }, + { key: "4", page: "usage", label: "Old Usage", icon: }, + ], + }, + { + key: "settings", + page: "settings", + label: "Settings", + icon: , + roles: all_admin_roles, + children: [ + { + key: "11", + page: "general-settings", + label: "Router Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "8", + page: "settings", + label: "Logging & Alerts", + icon: , + roles: all_admin_roles, + }, + { + key: "13", + page: "admin-panel", + label: "Admin Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "14", + page: "ui-theme", + label: "UI Theme", + icon: , + roles: all_admin_roles, + }, + ], + }, + ]; + + // ----- Filter by role without mutating originals ----- + const filteredMenuItems = React.useMemo(() => { + return menuItems + .filter((item) => !item.roles || item.roles.includes(userRole)) + .map((item) => ({ + ...item, + children: item.children ? item.children.filter((c) => !c.roles || c.roles.includes(userRole)) : undefined, + })); + }, [userRole]); + + // ----- Compute selected key from current path ----- + const selectedMenuKey = React.useMemo(() => { + const base = getBasePath(); + // strip base prefix and leading slash -> "virtual-keys", "tools/mcp-servers", etc. + const rel = pathname.startsWith(base) ? pathname.slice(base.length) : pathname.replace(/^\/+/, ""); + const relLower = rel.toLowerCase(); + + const matchesPath = (slug: string) => { + const route = routeFor(slug).toLowerCase(); + return relLower === route || relLower.startsWith(`${route}/`); + }; + + // search top-level + for (const item of filteredMenuItems) { + if (!item.children && matchesPath(item.page)) return item.key; + if (item.children) { + for (const child of item.children) { + if (matchesPath(child.page)) return child.key; + } + } + } + + // fallback to legacy defaultSelectedKey mapping + const fallback = filteredMenuItems.find((i) => i.page === defaultSelectedKey)?.key; + if (fallback) return fallback; + + for (const item of filteredMenuItems) { + if (item.children?.some((c) => c.page === defaultSelectedKey)) { + const child = item.children.find((c) => c.page === defaultSelectedKey)!; + return child.key; + } + } + + return "1"; + }, [pathname, filteredMenuItems, defaultSelectedKey]); + + // ----- Navigation ----- + const goTo = (slug: string) => { + const href = toHref(slug); + router.push(href); + }; + + return ( + + + + ({ + key: item.key, + icon: item.icon, + label: item.label, + children: item.children?.map((child) => ({ + key: child.key, + icon: child.icon, + label: child.label, + onClick: () => goTo(child.page), + })), + onClick: !item.children ? () => goTo(item.page) : undefined, + }))} + /> + + {isAdminRole(userRole) && !collapsed && } + + + ); +}; + +export default Sidebar2; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx new file mode 100644 index 000000000000..cb7f0f1a32c5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -0,0 +1,29 @@ +import useFeatureFlags from "@/hooks/useFeatureFlags"; +import Sidebar from "@/components/leftnav"; +import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +interface SidebarProviderProps { + defaultSelectedKey: string; + setPage: (newPage: string) => void; + sidebarCollapsed: boolean; +} + +const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => { + const { refactoredUIFlag } = useFeatureFlags(); + const { accessToken, userRole } = useAuthorized(); + + return refactoredUIFlag ? ( + + ) : ( + + ); +}; + +export default SidebarProvider; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/experimental/api-playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/experimental/api-playground/page.tsx new file mode 100644 index 000000000000..0948b7626db6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/experimental/api-playground/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import TransformRequestPanel from "@/components/transform_request"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const APIPlaygroundPage = () => { + const { accessToken } = useAuthorized(); + + return ; +}; + +export default APIPlaygroundPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/experimental/budgets/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/experimental/budgets/page.tsx new file mode 100644 index 000000000000..e49bd342c05f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/experimental/budgets/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import BudgetPanel from "@/components/budgets/budget_panel"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const BudgetsPage = () => { + const { accessToken } = useAuthorized(); + + return ; +}; + +export default BudgetsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/experimental/caching/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/experimental/caching/page.tsx new file mode 100644 index 000000000000..6dcbcdc697c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/experimental/caching/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import CacheDashboard from "@/components/cache_dashboard"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const CachingPage = () => { + const { token, accessToken, userRole, userId, premiumUser } = useAuthorized(); + + return ( + + ); +}; + +export default CachingPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/experimental/old-usage/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/experimental/old-usage/page.tsx new file mode 100644 index 000000000000..9521f4f69f1d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/experimental/old-usage/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Usage from "@/components/usage"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useState } from "react"; + +const OldUsagePage = () => { + const { accessToken, token, userRole, userId, premiumUser } = useAuthorized(); + const [keys, setKeys] = useState([]); + + return ( + + ); +}; + +export default OldUsagePage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/experimental/prompts/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/experimental/prompts/page.tsx new file mode 100644 index 000000000000..0836a03b7e7a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/experimental/prompts/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import PromptsPanel from "@/components/prompts"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const PromptsPage = () => { + const { accessToken } = useAuthorized(); + + return ; +}; + +export default PromptsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/experimental/tag-management/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/experimental/tag-management/page.tsx new file mode 100644 index 000000000000..0e686387b341 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/experimental/tag-management/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import TagManagement from "@/components/tag_management"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const TagManagementPage = () => { + const { accessToken, userId, userRole } = useAuthorized(); + + return ; +}; + +export default TagManagementPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/page.tsx new file mode 100644 index 000000000000..50cee215eb91 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import GuardrailsPanel from "@/components/guardrails"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const GuardrailsPage = () => { + const { accessToken } = useAuthorized(); + + return ; +}; + +export default GuardrailsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts new file mode 100644 index 000000000000..435000f9c887 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { jwtDecode } from "jwt-decode"; +import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; + +const useAuthorized = () => { + const router = useRouter(); + + const token = typeof document !== "undefined" ? getCookie("token") : null; + + // Redirect after mount if missing/invalid token + useEffect(() => { + if (!token) { + router.replace("/sso/key/generate"); + } + }, [token, router]); + + // Decode safely + const decoded = useMemo(() => { + if (!token) return null; + try { + return jwtDecode(token) as Record; + } catch { + // Bad token in cookie — clear and bounce + clearTokenCookies(); + router.replace("/sso/key/generate"); + return null; + } + }, [token, router]); + + return { + token: token, + accessToken: decoded?.key ?? null, + userId: decoded?.user_id ?? null, + userEmail: decoded?.user_email ?? null, + userRole: decoded?.user_role ?? null, + premiumUser: decoded?.premium_user ?? null, + disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null, + showSSOBanner: decoded?.login_method === "username_password" ?? false, + }; +}; + +export default useAuthorized; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTeams.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTeams.tsx new file mode 100644 index 000000000000..64cbf624f9c4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTeams.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { Team } from "@/components/key_team_helpers/key_list"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { fetchTeams } from "@/app/(dashboard)/networking"; + +const useTeams = () => { + const [teams, setTeams] = useState([]); + const { accessToken, userId: userID, userRole } = useAuthorized(); + + useEffect(() => { + (async () => { + const fetched = await fetchTeams(accessToken, userID, userRole, null); + setTeams(fetched); + })(); + }, [accessToken, userID, userRole]); + + return { teams, setTeams }; +}; + +export default useTeams; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx new file mode 100644 index 000000000000..68ab361356f2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -0,0 +1,73 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Navbar from "@/components/navbar"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useRouter, useSearchParams } from "next/navigation"; + +/** ---- BASE URL HELPERS ---- */ +function normalizeBasePrefix(raw: string | undefined | null): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) return ""; + const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); + return core ? `/${core}/` : "/"; +} +const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL); +function withBase(path: string): string { + const body = path.startsWith("/") ? path.slice(1) : path; + const combined = `${BASE_PREFIX}${body}`; + return combined.startsWith("/") ? combined : `/${combined}`; +} +/** -------------------------------- */ + +export default function Layout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { accessToken, userRole } = useAuthorized(); + const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); + const [page, setPage] = useState(() => { + return searchParams.get("page") || "api-keys"; + }); + + const updatePage = (newPage: string) => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set("page", newPage); + router.push(withBase(`/?${newSearchParams.toString()}`)); // always under BASE + setPage(newPage); + }; + + useEffect(() => { + setPage(searchParams.get("page") || "api-keys"); + }, [searchParams]); + + const toggleSidebar = () => setSidebarCollapsed((v) => !v); + + return ( + +
+ +
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx new file mode 100644 index 000000000000..b04a12e23066 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import SpendLogsTable from "@/components/view_logs"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const LogsPage = () => { + const { accessToken, token, userRole, userId, premiumUser } = useAuthorized(); + const { teams } = useTeams(); + + const queryClient = new QueryClient(); + + return ( + + + + ); +}; + +export default LogsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-hub/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-hub/page.tsx new file mode 100644 index 000000000000..86967b660fd5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-hub/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import ModelHubTable from "@/components/model_hub_table"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const ModelHubPage = () => { + const { accessToken, premiumUser, userRole } = useAuthorized(); + + return ; +}; + +export default ModelHubPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx new file mode 100644 index 000000000000..b661e4de3e77 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import ModelDashboard from "@/components/templates/model_dashboard"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { useState } from "react"; + +const ModelsAndEndpointsPage = () => { + const { token, accessToken, userRole, userId, premiumUser } = useAuthorized(); + const [keys, setKeys] = useState([]); + + const { teams } = useTeams(); + + return ( + {}} + premiumUser={premiumUser} + teams={teams} + /> + ); +}; + +export default ModelsAndEndpointsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/networking.ts b/ui/litellm-dashboard/src/app/(dashboard)/networking.ts new file mode 100644 index 000000000000..7fb09d61a5de --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/networking.ts @@ -0,0 +1,17 @@ +import { Organization, teamListCall } from "@/components/networking"; + +export const fetchTeams = async ( + accessToken: string, + userID: string | null, + userRole: string | null, + currentOrg: Organization | null, +) => { + let givenTeams; + if (userRole != "Admin" && userRole != "Admin Viewer") { + givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null, userID); + } else { + givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null); + } + + return givenTeams; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx new file mode 100644 index 000000000000..6112fac31619 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Organizations, { fetchOrganizations } from "@/components/organizations"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useEffect, useState } from "react"; +import { Organization } from "@/components/networking"; +import { fetchUserModels } from "@/components/organisms/create_key_button"; + +const OrganizationsPage = () => { + const { userId: userID, accessToken, userRole, premiumUser } = useAuthorized(); + const [organizations, setOrganizations] = useState([]); + const [userModels, setUserModels] = useState([]); + + useEffect(() => { + fetchOrganizations(accessToken, setOrganizations).then(() => {}); + }, [accessToken]); + + useEffect(() => { + fetchUserModels(userID, userRole, accessToken, setUserModels).then(() => {}); + }, [userID, userRole, accessToken]); + + return ( + + ); +}; + +export default OrganizationsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/settings/admin-settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/settings/admin-settings/page.tsx new file mode 100644 index 000000000000..c99242f0c3c4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/settings/admin-settings/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import AdminPanel from "@/components/admins"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useState } from "react"; +import { Team } from "@/components/key_team_helpers/key_list"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; + +const AdminSettings = () => { + const { teams, setTeams } = useTeams(); + + const [searchParams, setSearchParams] = useState(() => + typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search), + ); + const { accessToken, userId, premiumUser, showSSOBanner } = useAuthorized(); + + return ( + + ); +}; + +export default AdminSettings; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/settings/logging-and-alerts/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/settings/logging-and-alerts/page.tsx new file mode 100644 index 000000000000..b13e3c42f9e7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/settings/logging-and-alerts/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import Settings from "@/components/settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const LoggingAndAlertsPage = () => { + const { accessToken, userRole, userId, premiumUser } = useAuthorized(); + + return ; +}; + +export default LoggingAndAlertsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/settings/router-settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/settings/router-settings/page.tsx new file mode 100644 index 000000000000..2b5463cd81f6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/settings/router-settings/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import GeneralSettings from "@/components/general_settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const RouterSettingsPage = () => { + const { accessToken, userRole, userId } = useAuthorized(); + + return ; +}; + +export default RouterSettingsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/settings/ui-theme/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/settings/ui-theme/page.tsx new file mode 100644 index 000000000000..c6826cf11df5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/settings/ui-theme/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import UIThemeSettings from "@/components/ui_theme_settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const UIThemePage = () => { + const { userId, userRole, accessToken } = useAuthorized(); + + return ; +}; + +export default UIThemePage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx new file mode 100644 index 000000000000..42e6d250ee41 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Teams from "@/components/teams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { useEffect, useState } from "react"; +import { Organization } from "@/components/networking"; +import { fetchOrganizations } from "@/components/organizations"; + +const TeamsPage = () => { + const { accessToken, userId, userRole } = useAuthorized(); + const { teams, setTeams } = useTeams(); + const [searchParams, setSearchParams] = useState(() => + typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search), + ); + const [organizations, setOrganizations] = useState([]); + + useEffect(() => { + fetchOrganizations(accessToken, setOrganizations).then(() => {}); + }, [accessToken]); + + return ( + + ); +}; + +export default TeamsPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/test-key/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/test-key/page.tsx new file mode 100644 index 000000000000..6324f3c55560 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/test-key/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import ChatUI from "@/components/chat_ui/ChatUI"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const TestKeyPage = () => { + const { token, accessToken, userRole, userId, disabledPersonalKeyCreation } = useAuthorized(); + + return ( + + ); +}; + +export default TestKeyPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tools/mcp-servers/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tools/mcp-servers/page.tsx new file mode 100644 index 000000000000..1bea7ac74a57 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/tools/mcp-servers/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { MCPServers } from "@/components/mcp_tools"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const MCPServersPage = () => { + const { accessToken, userRole, userId } = useAuthorized(); + + const queryClient = new QueryClient(); + + return ( + + + + ); +}; + +export default MCPServersPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tools/vector-stores/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tools/vector-stores/page.tsx new file mode 100644 index 000000000000..8516a0faa1ad --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/tools/vector-stores/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import VectorStoreManagement from "@/components/vector_store_management"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const VectorStoresPage = () => { + const { accessToken, userId, userRole } = useAuthorized(); + + return ; +}; + +export default VectorStoresPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx new file mode 100644 index 000000000000..e4b44e5a450e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import NewUsagePage from "@/components/new_usage"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; + +const UsagePage = () => { + const { accessToken, userRole, userId, premiumUser } = useAuthorized(); + const { teams } = useTeams(); + + return ( + + ); +}; + +export default UsagePage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx new file mode 100644 index 000000000000..7cf401873df0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import ViewUserDashboard from "@/components/view_users"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const UsersPage = () => { + const { accessToken, userRole, userId, token } = useAuthorized(); + const [keys, setKeys] = useState([]); + + const { teams } = useTeams(); + const queryClient = new QueryClient(); + + return ( + + + + ); +}; + +export default UsersPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx new file mode 100644 index 000000000000..56f780b29625 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import useKeyList, { KeyResponse } from "@/components/key_team_helpers/key_list"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import UserDashboard from "@/components/user_dashboard"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { Organization } from "@/components/networking"; + +const VirtualKeysPage = () => { + const { accessToken, userRole, userId, premiumUser, userEmail } = useAuthorized(); + const { teams, setTeams } = useTeams(); + const [createClicked, setCreateClicked] = useState(false); + const [organizations, setOrganizations] = useState([]); + + const queryClient = new QueryClient(); + + const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({ + selectedKeyAlias: null, + currentOrg: null, + accessToken: accessToken || "", + createClicked, + }); + + const addKey = (data: any) => { + setKeys((prevData) => (prevData ? [...prevData, data] : [data])); + setCreateClicked(() => !createClicked); + }; + + return ( + + {}} + setUserEmail={() => {}} + setTeams={setTeams} + setKeys={setKeys} + premiumUser={premiumUser} + organizations={organizations} + addKey={addKey} + createClicked={createClicked} + /> + + ); +}; + +export default VirtualKeysPage; diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index 95c485fe2f03..9b6c027a56cc 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; const inter = Inter({ subsets: ["latin"] }); @@ -17,7 +18,12 @@ export default function RootLayout({ }>) { return ( - {children} + + + + {children} + + ); } diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 579e90e530c6..cefa680fc82c 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -23,7 +23,6 @@ import ModelHubTable from "@/components/model_hub_table"; import NewUsagePage from "@/components/new_usage"; import APIRef from "@/components/api_ref"; import ChatUI from "@/components/chat_ui/ChatUI"; -import Sidebar from "@/components/leftnav"; import Usage from "@/components/usage"; import CacheDashboard from "@/components/cache_dashboard"; import { getUiConfig, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking"; @@ -39,10 +38,37 @@ import VectorStoreManagement from "@/components/vector_store_management"; import UIThemeSettings from "@/components/ui_theme_settings"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; +import useFeatureFlags from "@/hooks/useFeatureFlags"; +import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; function getCookie(name: string) { - const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); - return cookieValue ? cookieValue.split("=")[1] : null; + // Safer cookie read + decoding; handles '=' inside values + const match = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); + if (!match) return null; + const value = match.slice(name.length + 1); + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function deleteCookie(name: string, path = "/") { + // Best-effort client-side clear (works for non-HttpOnly cookies without Domain) + document.cookie = `${name}=; Max-Age=0; Path=${path}`; +} + +function isJwtExpired(token: string): boolean { + try { + const decoded: any = jwtDecode(token); + if (decoded && typeof decoded.exp === "number") { + return decoded.exp * 1000 <= Date.now(); + } + return false; + } catch { + // If we can't decode, treat as invalid/expired + return true; + } } function formatUserRole(userRole: string) { @@ -115,6 +141,7 @@ export default function CreateKeyPage() { const [createClicked, setCreateClicked] = useState(false); const [authLoading, setAuthLoading] = useState(true); const [userID, setUserID] = useState(null); + const { refactoredUIFlag } = useFeatureFlags(); const invitation_id = searchParams.get("invitation_id"); @@ -149,17 +176,42 @@ export default function CreateKeyPage() { const redirectToLogin = authLoading === false && token === null && invitation_id === null; useEffect(() => { - const token = getCookie("token"); - getUiConfig().then((data) => { - // get the information for constructing the proxy base url, and then set the token and auth loading - setToken(token); - setAuthLoading(false); - }); + let cancelled = false; + + (async () => { + try { + await getUiConfig(); // ensures proxyBaseUrl etc. are ready + } catch { + // proceed regardless; we still need to decide auth state + } + + if (cancelled) return; + + const raw = getCookie("token"); + const valid = raw && !isJwtExpired(raw) ? raw : null; + + // If token exists but is invalid/expired, clear it so downstream code + // doesn't keep trying to use it and cause redirect spasms. + if (raw && !valid) { + deleteCookie("token", "/"); + } + + if (!cancelled) { + setToken(valid); + setAuthLoading(false); + } + })(); + + return () => { + cancelled = true; + }; }, []); useEffect(() => { if (redirectToLogin) { - window.location.href = (proxyBaseUrl || "") + "/sso/key/generate"; + // Replace instead of assigning to avoid back-button loops + const dest = (proxyBaseUrl || "") + "/sso/key/generate"; + window.location.replace(dest); } }, [redirectToLogin]); @@ -168,7 +220,23 @@ export default function CreateKeyPage() { return; } - const decoded = jwtDecode(token) as { [key: string]: any }; + // Defensive: re-check expiry in case cookie changed after mount + if (isJwtExpired(token)) { + deleteCookie("token", "/"); + setToken(null); + return; + } + + let decoded: any = null; + try { + decoded = jwtDecode(token); + } catch { + // Malformed token → treat as unauthenticated + deleteCookie("token", "/"); + setToken(null); + return; + } + if (decoded) { // set accessToken setAccessToken(decoded.key); @@ -258,13 +326,7 @@ export default function CreateKeyPage() { />
- +
{page == "api-keys" ? ( diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 07642880570a..37cb5a12284f 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import React, { useState, useEffect } from "react"; import type { MenuProps } from "antd"; -import { Dropdown, Tooltip } from "antd"; +import { Dropdown, Tooltip, Switch } from "antd"; import { getProxyBaseUrl, Organization } from "@/components/networking"; import { defaultOrg } from "@/components/common_components/default_org"; import { @@ -19,6 +19,7 @@ import { clearTokenCookies } from "@/utils/cookieUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; import { useTheme } from "@/contexts/ThemeContext"; import { clearMCPAuthTokens } from "./mcp_tools/mcp_auth_storage"; +import useFeatureFlags from "@/hooks/useFeatureFlags"; interface NavbarProps { userID: string | null; @@ -43,11 +44,12 @@ const Navbar: React.FC = ({ accessToken, isPublicPage = false, sidebarCollapsed = false, - onToggleSidebar, + onToggleSidebar }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); const { logoUrl } = useTheme(); + const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); // Simple logo URL: use custom logo if available, otherwise default const imageUrl = logoUrl || `${baseUrl}/get_image`; @@ -79,6 +81,8 @@ const Navbar: React.FC = ({ const userItems: MenuProps["items"] = [ { key: "user-info", + // Prevent dropdown from closing when interacting with the toggle + onClick: (info) => info.domEvent?.stopPropagation(), label: (
@@ -115,6 +119,18 @@ const Navbar: React.FC = ({ {userEmail || "Unknown"}
+ + {/* NEW: Feature flag label + toggle below the email field */} +
+ Refactored UI + setRefactoredUIFlag(checked)} + aria-label="Toggle refactored UI feature flag" + /> +
), diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 5f27c277d43b..2ce673ee548b 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -34,18 +34,34 @@ if (isLocal != true) { console.log = function () {}; } +const getWindowLocation = () => { + if (typeof window === "undefined") { + return null; + } + return window.location; +}; + const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string | null = null) => { /** * Special function for updating the proxy base url. Should only be called by getUiConfig. */ - const defaultProxyBaseUrl = isLocal ? "http://localhost:4000" : window.location.origin; - let initialProxyBaseUrl = receivedProxyBaseUrl || defaultProxyBaseUrl; + const browserLocation = getWindowLocation(); + const resolvedDefaultProxyBaseUrl = isLocal ? "http://localhost:4000" : browserLocation?.origin ?? null; + let initialProxyBaseUrl = receivedProxyBaseUrl || resolvedDefaultProxyBaseUrl; console.log("proxyBaseUrl:", proxyBaseUrl); console.log("serverRootPath:", serverRootPath); + + if (!initialProxyBaseUrl) { + proxyBaseUrl = proxyBaseUrl ?? null; + console.log("Updated proxyBaseUrl:", proxyBaseUrl); + return; + } + if (serverRootPath.length > 0 && !initialProxyBaseUrl.endsWith(serverRootPath) && serverRootPath != "/") { initialProxyBaseUrl += serverRootPath; - proxyBaseUrl = initialProxyBaseUrl; } + + proxyBaseUrl = initialProxyBaseUrl; console.log("Updated proxyBaseUrl:", proxyBaseUrl); }; @@ -54,7 +70,11 @@ const updateServerRootPath = (receivedServerRootPath: string) => { }; export const getProxyBaseUrl = (): string => { - return proxyBaseUrl ? proxyBaseUrl : window.location.origin; + if (proxyBaseUrl) { + return proxyBaseUrl; + } + const browserLocation = getWindowLocation(); + return browserLocation?.origin ?? ""; }; const HTTP_REQUEST = { @@ -159,7 +179,10 @@ const handleError = async (errorData: string) => { NotificationsManager.info("UI Session Expired. Logging out."); lastErrorTime = currentTime; clearTokenCookies(); - window.location.href = window.location.pathname; + const browserLocation = getWindowLocation(); + if (browserLocation) { + window.location.href = browserLocation.pathname; + } } lastErrorTime = currentTime; } else { diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index c725466b9820..c37d94abb1ab 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -131,6 +131,13 @@ export const fetchUserModels = async ( } }; +/** + * ───────────────────────────────────────────────────────────────────────── + * @deprecated + * This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/CreateKey.tsx + * Please contribute to the new refactor. + * ───────────────────────────────────────────────────────────────────────── + */ const CreateKey: React.FC = ({ userID, team, diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index a5ec1774252f..26adaa11dbd4 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -47,6 +47,13 @@ interface KeyInfoViewProps { backButtonText?: string; } +/** + * ───────────────────────────────────────────────────────────────────────── + * @deprecated + * This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx + * Please contribute to the new refactor. + * ───────────────────────────────────────────────────────────────────────── + */ export default function KeyInfoView({ keyId, onClose, diff --git a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx index bad8bdb98338..86903d555301 100644 --- a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx @@ -90,7 +90,7 @@ interface ViewKeyTableProps { selectedTeam: Team | null; setSelectedTeam: React.Dispatch>; data: KeyResponse[] | null; - setData: React.Dispatch>; + setData: (keys: KeyResponse[]) => void; teams: Team[] | null; premiumUser: boolean; currentOrg: Organization | null; diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index b6470e495212..4d98b37a8f8e 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -20,11 +20,12 @@ import ViewUserTeam from "./view_user_team"; import DashboardTeam from "./dashboard_default_team"; import Onboarding from "../app/onboarding/page"; import { useSearchParams, useRouter } from "next/navigation"; -import { Team } from "./key_team_helpers/key_list"; +import { KeyResponse, Team } from "./key_team_helpers/key_list"; import { jwtDecode } from "jwt-decode"; import { Typography } from "antd"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearMCPAuthTokens } from "./mcp_tools/mcp_auth_storage"; +import { Setter } from "@/types"; export interface ProxySettings { PROXY_BASE_URL: string | null; @@ -56,7 +57,7 @@ interface UserDashboardProps { setUserRole: React.Dispatch>; setUserEmail: React.Dispatch>; setTeams: React.Dispatch>; - setKeys: React.Dispatch>; + setKeys: (keys: KeyResponse[]) => void; premiumUser: boolean; organizations: Organization[] | null; addKey: (data: any) => void; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 19479ce21a96..7fcb7f54b82d 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -30,6 +30,8 @@ import { updateExistingKeys } from "@/utils/dataUtils"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "./molecules/notifications_manager"; +import { Setter } from "@/types"; +import { KeyResponse } from "@/components/key_team_helpers/key_list"; interface ViewUserDashboardProps { accessToken: string | null; diff --git a/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx new file mode 100644 index 000000000000..d3d676d2d212 --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx @@ -0,0 +1,112 @@ +"use client"; + +const getBasePath = () => { + const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; + const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes + return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash +}; + +import React, { createContext, useContext, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; // ⟵ add this + +type Flags = { + refactoredUIFlag: boolean; + setRefactoredUIFlag: (v: boolean) => void; +}; + +const STORAGE_KEY = "feature.refactoredUIFlag"; + +const FeatureFlagsCtx = createContext(null); + +/** Safely read the flag from localStorage. If anything goes wrong, reset to false. */ +function readFlagSafely(): boolean { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null) { + localStorage.setItem(STORAGE_KEY, "false"); + return false; + } + + const v = raw.trim().toLowerCase(); + if (v === "true" || v === "1") return true; + if (v === "false" || v === "0") return false; + + // Last chance: try JSON.parse in case something odd was stored. + const parsed = JSON.parse(raw); + if (typeof parsed === "boolean") return parsed; + + // Malformed → reset to false + localStorage.setItem(STORAGE_KEY, "false"); + return false; + } catch { + // If even accessing localStorage throws, best effort reset then default to false + try { + localStorage.setItem(STORAGE_KEY, "false"); + } catch {} + return false; + } +} + +function writeFlagSafely(v: boolean) { + try { + localStorage.setItem(STORAGE_KEY, String(v)); + } catch { + // Ignore write errors; state will still reflect the intended value. + } +} + +export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); // ⟵ add this + + // Lazy init reads from localStorage only on the client + const [refactoredUIFlag, setRefactoredUIFlagState] = useState(() => readFlagSafely()); + + const setRefactoredUIFlag = (v: boolean) => { + setRefactoredUIFlagState(v); + writeFlagSafely(v); + }; + + // Keep this flag in sync across tabs/windows. + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY && e.newValue != null) { + const next = e.newValue.trim().toLowerCase(); + setRefactoredUIFlagState(next === "true" || next === "1"); + } + // If the key was cleared elsewhere, self-heal to false. + if (e.key === STORAGE_KEY && e.newValue === null) { + writeFlagSafely(false); + setRefactoredUIFlagState(false); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + // Redirect to base path the moment the flag is OFF. + useEffect(() => { + if (refactoredUIFlag) return; // only act when turned off + + const base = getBasePath(); + const normalize = (p: string) => (p.endsWith("/") ? p : p + "/"); + const current = normalize(window.location.pathname); + + // Avoid a redirect loop if we're already at the base path. + if (current !== base) { + // Replace so the "off" redirect doesn't pollute history. + router.replace(base); + } + }, [refactoredUIFlag, router]); + + return ( + {children} + ); +}; + +const useFeatureFlags = () => { + const ctx = useContext(FeatureFlagsCtx); + if (!ctx) throw new Error("useFeatureFlags must be used within FeatureFlagsProvider"); + return ctx; +}; + +export default useFeatureFlags; diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index 2cc9ef61ebdf..23682fd6e814 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -6,6 +6,10 @@ * Clears the token cookie from both root and /ui paths */ export function clearTokenCookies() { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + // Get the current domain const domain = window.location.hostname; @@ -37,6 +41,7 @@ export function clearTokenCookies() { * @returns The cookie value or null if not found */ export function getCookie(name: string) { + if (typeof document === "undefined") return null; const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); return cookieValue ? cookieValue.split("=")[1] : null; } diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index aafeb010bebc..f542da105f5a 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -5,7 +5,7 @@ export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names]; export const internalUserRoles = ["Internal User", "Internal Viewer"]; export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"]; -export const rolesWithWriteAccess = ["Internal User", "Admin"]; +export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"]; // Helper function to check if a role is in all_admin_roles export const isAdminRole = (role: string): boolean => {