diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index e98cea9261e..44294b5fa97 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -3,8 +3,8 @@ "no-console": { "max": 12, "target": 0 }, "complexity": { "max": 140, "target": 80 }, "max-depth": { "max": 70, "target": 30 }, - "local/no-large-inline-object-arg": { "max": 554, "target": 300 }, - "local/no-long-condition-chain": { "max": 265, "target": 120 }, + "local/no-large-inline-object-arg": { "max": 551, "target": 300 }, + "local/no-long-condition-chain": { "max": 196, "target": 120 }, "testing-library/no-container": { "max": 133, "target": 50 }, "testing-library/no-node-access": { "max": 707, "target": 500 }, "testing-library/prefer-screen-queries": { "max": 18, "target": 18 } diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index d7475173b90..76ac60a6453 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1619,14 +1619,6 @@ "count": 1 } }, - "src/components/common_components/fetch_teams.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "max-params": { - "count": 1 - } - }, "src/components/common_components/simple_table.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1823,7 +1815,7 @@ "count": 5 }, "no-restricted-syntax": { - "count": 152 + "count": 150 }, "prefer-const": { "count": 32 @@ -1871,9 +1863,6 @@ "src/components/per_user_usage.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/components/permissions/MCPServerPermissions.tsx": { @@ -2303,17 +2292,6 @@ "count": 1 } }, - "src/components/user_dashboard.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "prefer-const": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/vector_store_management/types.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx index 13689afcb52..2e3b000dfec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx @@ -1,59 +1,90 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { userDashboardSpy } = vi.hoisted(() => ({ - userDashboardSpy: vi.fn((_props: Record) => null), +const { teamListCall, authorizedSession } = vi.hoisted(() => ({ + teamListCall: vi.fn(() => new Promise(() => {})), + authorizedSession: vi.fn(), })); -vi.mock("@/components/user_dashboard", () => ({ - default: (props: Record) => userDashboardSpy(props), -})); +const session = (overrides: { userRole?: string; isViewOnly?: boolean } = {}) => ({ + isLoading: false, + isAuthorized: true, + token: "jwt", + accessToken: "sk-access", + userId: "u-123", + userEmail: "admin@example.com", + userRole: "Admin", + isViewOnly: false, + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + ...overrides, +}); -// AuthContext is still hydrating: userID has not been populated yet (the regression). -vi.mock("@/contexts/AuthContext", () => ({ - useAuth: () => ({ - userID: null, - userRole: "", - userEmail: null, - accessToken: null, - premiumUser: false, - setUserRole: vi.fn(), - setUserEmail: vi.fn(), - }), -})); - -// useAuthorized decodes the cookie synchronously, so identity is already available. vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => ({ - isLoading: false, - isAuthorized: true, - token: "jwt", - accessToken: "sk-access", - userId: "u-123", - userEmail: "admin@example.com", - userRole: "Admin", - premiumUser: false, - disabledPersonalKeyCreation: false, - showSSOBanner: false, - }), + default: () => authorizedSession(), })); vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ - teamListCall: vi.fn(() => new Promise(() => {})), + teamListCall, })); vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(""), })); +vi.mock("@/components/VirtualKeysPage/VirtualKeysTable", () => ({ + VirtualKeysTable: ({ headerActions }: { headerActions?: React.ReactNode }) => ( +
+ {headerActions} + + + ), +})); + +vi.mock("@/components/organisms/create_key_button", () => ({ + default: () => , +})); + import ApiKeysDashboard from "./ApiKeysDashboard"; -describe("ApiKeysDashboard identity source", () => { - it("passes the useAuthorized userID through even while AuthContext.userID is still null", () => { +describe("ApiKeysDashboard", () => { + beforeEach(() => { + teamListCall.mockClear(); + authorizedSession.mockReturnValue(session()); + sessionStorage.clear(); + }); + + it("scopes the team list to the signed-in user for non-admin roles", () => { + authorizedSession.mockReturnValue(session({ userRole: "Internal User" })); render(); - expect(userDashboardSpy).toHaveBeenCalled(); - const props = userDashboardSpy.mock.calls[0][0]; - expect(props.userID).toBe("u-123"); + expect(teamListCall).toHaveBeenCalledWith("sk-access", 1, 100, { userID: "u-123" }); + }); + + it("renders the keys table with a Create Key action for roles that can write", () => { + render(); + + expect(screen.getByRole("table", { name: "Virtual Keys" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create Key" })).toBeInTheDocument(); + }); + + it("hides Create Key for view-only roles", () => { + authorizedSession.mockReturnValue(session({ isViewOnly: true })); + render(); + + expect(screen.getByRole("table", { name: "Virtual Keys" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Create Key" })).not.toBeInTheDocument(); + }); + + it("leaves other pages' session state intact when the tab reloads", () => { + sessionStorage.setItem("chatHistory", '[{"role":"user","content":"hi"}]'); + sessionStorage.setItem("selectedModel", "gpt-5.5"); + render(); + + window.dispatchEvent(new Event("beforeunload")); + + expect(sessionStorage.getItem("chatHistory")).toBe('[{"role":"user","content":"hi"}]'); + expect(sessionStorage.getItem("selectedModel")).toBe("gpt-5.5"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx index ae0c443910a..376fee72b88 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx @@ -3,22 +3,17 @@ import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; -import { CreateKeyPrefillData } from "@/components/organisms/create_key_button"; -import UserDashboard from "@/components/user_dashboard"; -import { useAuth } from "@/contexts/AuthContext"; +import CreateKey, { CreateKeyPrefillData } from "@/components/organisms/create_key_button"; +import { VirtualKeysTable } from "@/components/VirtualKeysPage/VirtualKeysTable"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; export default function ApiKeysDashboard() { - // Identity comes from useAuthorized (synchronous cookie decode) so userID is set whenever the - // route is authorized; useAuth only supplies the backfill setters UserDashboard still expects. - const { userId: userID, userRole, userEmail, accessToken, premiumUser } = useAuthorized(); - const { setUserRole, setUserEmail } = useAuth(); + const { userId: userID, userRole, accessToken, isViewOnly } = useAuthorized(); const searchParams = useSearchParams()!; const [teams, setTeams] = useState(null); const [keys, setKeys] = useState([]); - const [createClicked, setCreateClicked] = useState(false); const autoOpenCreate = searchParams.get("create") === "true"; const prefillData: CreateKeyPrefillData | undefined = useMemo(() => { @@ -63,7 +58,6 @@ export default function ApiKeysDashboard() { const addKey = (data: KeyResponse) => { setKeys((prevData) => (prevData ? [...prevData, data] : [data])); - setCreateClicked((prev) => !prev); }; useEffect(() => { @@ -77,21 +71,21 @@ export default function ApiKeysDashboard() { }, [accessToken, userID, userRole]); return ( - +
+ + ) + } + /> +
); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useProxySettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useProxySettings.ts index 82cefd800f4..7925af223ae 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useProxySettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useProxySettings.ts @@ -8,6 +8,8 @@ export interface ProxySettings { PROXY_BASE_URL: string; PROXY_LOGOUT_URL: string; LITELLM_UI_API_DOC_BASE_URL?: string | null; + DISABLE_EXPENSIVE_DB_QUERIES?: boolean; + NUM_SPEND_LOGS_ROWS?: number; } const EMPTY_PROXY_SETTINGS: ProxySettings = { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index 42be1b34f07..889a17bc88d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import ViewUserSpend from "@/components/view_user_spend"; -import { ProxySettings } from "@/components/user_dashboard"; +import { ProxySettings } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; diff --git a/ui/litellm-dashboard/src/components/common_components/fetch_teams.tsx b/ui/litellm-dashboard/src/components/common_components/fetch_teams.tsx deleted file mode 100644 index ca82fdfb144..00000000000 --- a/ui/litellm-dashboard/src/components/common_components/fetch_teams.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { teamListCall, Organization } from "../networking"; - -export const fetchTeams = async ( - accessToken: string, - userID: string | null, - userRole: string | null, - currentOrg: Organization | null, - setTeams: (teams: any[]) => void, -) => { - 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); - } - - setTeams(givenTeams); -}; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 144c02dfcd1..a7597c30404 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -140,7 +140,7 @@ const resolveDefaultBase = (fallback: string | null): string | null => const defaultProxyBaseUrl = resolveDefaultBase(null); const WORKER_URL_KEY = "litellm_worker_url"; // If a worker URL is in localStorage, use it as the initial proxyBaseUrl. -// This survives page navigation and the sessionStorage.clear() in user_dashboard. +// This survives page navigation. const _rawWorkerUrl = typeof window !== "undefined" ? window.localStorage.getItem(WORKER_URL_KEY) : null; // Validate stored worker URL — reject non-HTTP schemes to prevent exfiltration const _initialWorkerUrl = (() => { @@ -195,10 +195,9 @@ export const getProxyBaseUrl = (): string => { /** * Switch API calls to point at a worker (or back to the control plane). - * Persists to localStorage so it survives page navigation and the - * sessionStorage.clear() in user_dashboard. Also updates the module-level - * proxyBaseUrl so in-flight code in this JS execution sees the new value - * immediately. + * Persists to localStorage so it survives page navigation. Also updates the + * module-level proxyBaseUrl so in-flight code in this JS execution sees the + * new value immediately. */ function isValidHttpUrl(url: string): boolean { try { diff --git a/ui/litellm-dashboard/src/components/user_dashboard.test.tsx b/ui/litellm-dashboard/src/components/user_dashboard.test.tsx deleted file mode 100644 index 01764613bf5..00000000000 --- a/ui/litellm-dashboard/src/components/user_dashboard.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; -import { cleanup } from "@testing-library/react"; -import React from "react"; -import { renderWithProviders } from "../../tests/test-utils"; - -// Track addEventListener/removeEventListener calls for "beforeunload" -const addEventListenerSpy = vi.spyOn(window, "addEventListener"); -const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); - -// Mock next/navigation -vi.mock("next/navigation", () => ({ - useSearchParams: () => new URLSearchParams(), -})); - -// Mock networking with importOriginal so all exports are available -vi.mock("./networking", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"), - getProxyUISettings: vi.fn().mockResolvedValue({}), - keyInfoCall: vi.fn().mockResolvedValue({}), - modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), - userGetInfoV2: vi.fn().mockResolvedValue({ - user_id: "user-1", - user_email: "test@example.com", - spend: 0, - max_budget: null, - models: [], - teams: [], - }), - }; -}); - -// Mock jwt-decode to return a valid token structure -vi.mock("jwt-decode", () => ({ - jwtDecode: vi.fn().mockReturnValue({ - key: "test-access-token", - user_role: "proxy_admin", - user_email: "test@example.com", - exp: Math.floor(Date.now() / 1000) + 3600, - }), -})); - -// Mock cookie utility -vi.mock("@/utils/cookieUtils", () => ({ - clearTokenCookies: vi.fn(), - getCookie: vi.fn().mockReturnValue("fake-jwt-token"), -})); - -// Mock fetchTeams -vi.mock("./common_components/fetch_teams", () => ({ - fetchTeams: vi.fn(), -})); - -// Mock heavy child components to isolate UserDashboard behavior -vi.mock("./organisms/create_key_button", () => ({ - default: () =>
, -})); - -vi.mock("./VirtualKeysPage/VirtualKeysTable", () => ({ - VirtualKeysTable: () =>
, -})); - -vi.mock("../app/onboarding/page", () => ({ - default: () =>
, -})); - -// Provide a token cookie so the component doesn't redirect to login -Object.defineProperty(document, "cookie", { - writable: true, - value: "token=fake-jwt-token", -}); - -import UserDashboard from "./user_dashboard"; - -const defaultProps = { - userID: "user-1", - userRole: "Admin", - userEmail: "test@example.com", - teams: [] as any[], - keys: [] as any[], - setUserRole: vi.fn(), - setUserEmail: vi.fn(), - setTeams: vi.fn(), - setKeys: vi.fn(), - premiumUser: false, - addKey: vi.fn(), - createClicked: false, -}; - -function renderDashboard(props = {}) { - return renderWithProviders(); -} - -describe("UserDashboard beforeunload listener", () => { - beforeEach(() => { - addEventListenerSpy.mockClear(); - removeEventListenerSpy.mockClear(); - }); - - afterEach(() => { - cleanup(); - }); - - it("registers exactly one beforeunload listener on mount", () => { - renderDashboard(); - - const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter(([event]) => event === "beforeunload"); - expect(beforeUnloadCalls).toHaveLength(1); - }); - - it("does not add duplicate listeners on re-render", () => { - const { rerender } = renderWithProviders(); - - addEventListenerSpy.mockClear(); - - // Re-render with different props to trigger a render cycle - rerender(); - - const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter(([event]) => event === "beforeunload"); - expect(beforeUnloadCalls).toHaveLength(0); - }); - - it("removes the beforeunload listener on unmount", () => { - const { unmount } = renderDashboard(); - - unmount(); - - const removeCalls = removeEventListenerSpy.mock.calls.filter(([event]) => event === "beforeunload"); - expect(removeCalls).toHaveLength(1); - }); -}); diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx deleted file mode 100644 index dcadc141103..00000000000 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client"; -import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; -import { jwtDecode } from "jwt-decode"; -import React, { useEffect, useState } from "react"; -import { fetchTeams } from "./common_components/fetch_teams"; -import { KeyResponse, Team } from "./key_team_helpers/key_list"; -import { effectiveSessionRole } from "@/utils/roles"; -import { getProxyBaseUrl, keyInfoCall, modelAvailableCall, Organization, userGetInfoV2 } from "./networking"; -import CreateKey, { CreateKeyPrefillData } from "./organisms/create_key_button"; -import { VirtualKeysTable } from "./VirtualKeysPage/VirtualKeysTable"; - -export interface ProxySettings { - PROXY_BASE_URL: string | null; - PROXY_LOGOUT_URL: string | null; - LITELLM_UI_API_DOC_BASE_URL?: string | null; - DEFAULT_TEAM_DISABLED: boolean; - SSO_ENABLED: boolean; - DISABLE_EXPENSIVE_DB_QUERIES: boolean; - NUM_SPEND_LOGS_ROWS: number; -} - -export type UserInfo = { - models: string[]; - max_budget?: number | null; - spend: number; -}; - -interface UserDashboardProps { - userID: string | null; - userRole: string | null; - userEmail: string | null; - teams: Team[] | null; - keys: any[] | null; - setUserRole: React.Dispatch>; - setUserEmail: React.Dispatch>; - setTeams: React.Dispatch>; - setKeys: (keys: KeyResponse[]) => void; - premiumUser: boolean; - addKey: (data: any) => void; - createClicked: boolean; - autoOpenCreate?: boolean; - prefillData?: CreateKeyPrefillData; -} - -const UserDashboard: React.FC = ({ - userID, - userRole, - teams, - keys, - setUserRole, - userEmail, - setUserEmail, - setTeams, - setKeys, - premiumUser, - addKey, - createClicked, - autoOpenCreate, - prefillData, -}) => { - const [userSpendData, setUserSpendData] = useState(null); - const [currentOrg] = useState(null); - - const token = getCookie("token"); - - const [accessToken, setAccessToken] = useState(null); - const [selectedTeam] = useState(null); - - // Clear session storage on page unload so next load fetches fresh data. - // Note: MCP auth tokens are persistent and should not be cleared on page refresh - // They are only cleared on logout - useEffect(() => { - const handleBeforeUnload = () => { - const token = sessionStorage.getItem("token"); - sessionStorage.clear(); - if (token) { - sessionStorage.setItem("token", token); - } - }; - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, []); - - // console.log(`selectedTeam: ${Object.entries(selectedTeam)}`); - // Moved useEffect inside the component and used a condition to run fetch only if the params are available - useEffect(() => { - if (token) { - const decoded = jwtDecode(token) as { [key: string]: any }; - if (decoded) { - // cast decoded to dictionary - - // set accessToken - setAccessToken(decoded.key); - - // check if userRole is defined - if (decoded.user_role) { - setUserRole(effectiveSessionRole(decoded.user_role)); - } else { - } - - if (decoded.user_email) { - setUserEmail(decoded.user_email); - } else { - } - } - } - if (userID && accessToken && userRole && !userSpendData) { - const cachedUserModels = sessionStorage.getItem("userModels" + userID); - if (!cachedUserModels) { - const fetchData = async () => { - try { - const response = await userGetInfoV2(accessToken, userID); - - setUserSpendData(response); - - sessionStorage.setItem("userSpendData" + userID, JSON.stringify(response)); - - const model_available = await modelAvailableCall(accessToken, userID, userRole); - // loop through model_info["data"] and create an array of element.model_name - let available_model_names = model_available["data"].map((element: { id: string }) => element.id); - - sessionStorage.setItem("userModels" + userID, JSON.stringify(available_model_names)); - } catch (error: any) { - console.error("There was an error fetching the data", error); - if (error.message.includes("Invalid proxy server token passed")) { - gotoLogin(); - } - // Optionally, update your UI to reflect the error state here as well - } - }; - fetchData(); - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } - } - }, [userID, token, accessToken, userRole]); - - useEffect(() => { - // check key health - if it's invalid, redirect to login - if (accessToken) { - const fetchKeyInfo = async () => { - try { - await keyInfoCall(accessToken, [accessToken]); - } catch (error: any) { - if (error.message.includes("Invalid proxy server token passed")) { - gotoLogin(); - } - } - }; - fetchKeyInfo(); - } - }, [accessToken]); - - useEffect(() => { - if (accessToken) { - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } - }, [currentOrg]); - - function gotoLogin() { - // Clear token cookies using the utility function - clearTokenCookies(); - - const baseUrl = getProxyBaseUrl(); - - const url = baseUrl ? `${baseUrl}/sso/key/generate` : `/sso/key/generate`; - - window.location.href = url; - - return null; - } - - if (token == null) { - // user is not logged in as yet - - // Clear token cookies using the utility function - gotoLogin(); - return null; - } else { - // Check if token is expired - try { - const decoded = jwtDecode(token) as { [key: string]: any }; - const expTime = decoded.exp; - const currentTime = Math.floor(Date.now() / 1000); - - if (expTime && currentTime >= expTime) { - gotoLogin(); - - return null; - } - } catch (error) { - console.error("Error decoding token:", error); - // If there's an error decoding the token, consider it invalid - clearTokenCookies(); - - gotoLogin(); - - return null; - } - - if (accessToken == null) { - return null; - } - } - - if (userID == null) { - return

User ID is not set

; - } - - if (userRole == null) { - setUserRole("App Owner"); - } - - // Admin Viewer can view keys read-only — gate "Create Key" but render the - // virtual-keys table the same as for Proxy Admin (read parity). Every - // other role keeps its existing ability to create keys. - const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer"; - - return ( -
- - ) : undefined - } - /> -
- ); -}; - -export default UserDashboard;