Merge pull request #39991 from BerriAI/litellm_remove_legacy_user_dashboard

refactor(ui): render the Virtual Keys page without the legacy user dashboard
This commit is contained in:
ryan-crabbe-berri 2026-09-05 18:11:12 -07:00 committed by GitHub
commit 09e9fd5f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 102 additions and 489 deletions

View file

@ -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 }

View file

@ -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

View file

@ -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<string, unknown>) => null),
const { teamListCall, authorizedSession } = vi.hoisted(() => ({
teamListCall: vi.fn(() => new Promise(() => {})),
authorizedSession: vi.fn(),
}));
vi.mock("@/components/user_dashboard", () => ({
default: (props: Record<string, unknown>) => 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 }) => (
<div>
{headerActions}
<table aria-label="Virtual Keys" />
</div>
),
}));
vi.mock("@/components/organisms/create_key_button", () => ({
default: () => <button type="button">Create Key</button>,
}));
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(<ApiKeysDashboard />);
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(<ApiKeysDashboard />);
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(<ApiKeysDashboard />);
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(<ApiKeysDashboard />);
window.dispatchEvent(new Event("beforeunload"));
expect(sessionStorage.getItem("chatHistory")).toBe('[{"role":"user","content":"hi"}]');
expect(sessionStorage.getItem("selectedModel")).toBe("gpt-5.5");
});
});

View file

@ -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<Team[] | null>(null);
const [keys, setKeys] = useState<KeyResponse[] | null>([]);
const [createClicked, setCreateClicked] = useState<boolean>(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 (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser ?? false}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
addKey={addKey}
createClicked={createClicked}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
<main className="flex h-full flex-col p-8">
<VirtualKeysTable
headerActions={
isViewOnly ? undefined : (
<CreateKey
team={null}
teams={teams}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
)
}
/>
</main>
);
}

View file

@ -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 = {

View file

@ -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";

View file

@ -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);
};

View file

@ -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 {

View file

@ -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<typeof import("./networking")>();
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: () => <div data-testid="create-key-mock" />,
}));
vi.mock("./VirtualKeysPage/VirtualKeysTable", () => ({
VirtualKeysTable: () => <div data-testid="virtual-keys-table-mock" />,
}));
vi.mock("../app/onboarding/page", () => ({
default: () => <div data-testid="onboarding-mock" />,
}));
// 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(<UserDashboard {...defaultProps} {...props} />);
}
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(<UserDashboard {...defaultProps} />);
addEventListenerSpy.mockClear();
// Re-render with different props to trigger a render cycle
rerender(<UserDashboard {...defaultProps} userEmail="updated@example.com" />);
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);
});
});

View file

@ -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<React.SetStateAction<string>>;
setUserEmail: React.Dispatch<React.SetStateAction<string | null>>;
setTeams: React.Dispatch<React.SetStateAction<Team[] | null>>;
setKeys: (keys: KeyResponse[]) => void;
premiumUser: boolean;
addKey: (data: any) => void;
createClicked: boolean;
autoOpenCreate?: boolean;
prefillData?: CreateKeyPrefillData;
}
const UserDashboard: React.FC<UserDashboardProps> = ({
userID,
userRole,
teams,
keys,
setUserRole,
userEmail,
setUserEmail,
setTeams,
setKeys,
premiumUser,
addKey,
createClicked,
autoOpenCreate,
prefillData,
}) => {
const [userSpendData, setUserSpendData] = useState<UserInfo | null>(null);
const [currentOrg] = useState<Organization | null>(null);
const token = getCookie("token");
const [accessToken, setAccessToken] = useState<string | null>(null);
const [selectedTeam] = useState<any | null>(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 <h1>User ID is not set</h1>;
}
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 (
<main className="flex h-full flex-col p-8">
<VirtualKeysTable
headerActions={
canCreateKey ? (
<CreateKey
key={selectedTeam ? selectedTeam.team_id : null}
team={selectedTeam as Team | null}
teams={teams as Team[]}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : undefined
}
/>
</main>
);
};
export default UserDashboard;

View file

@ -105,7 +105,7 @@ vi.mock("@/utils/returnUrlUtils", async (importOriginal) => {
// Super-light stubs for all heavy components so rendering doesn't explode
vi.mock("@/components/navbar", () => ({ default: stub("navbar") }));
vi.mock("@/components/user_dashboard", () => ({ default: stub("user-dashboard") }));
vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ default: stub("api-keys-dashboard") }));
vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") }));
vi.mock("@/components/teams", () => ({ default: stub("teams") }));
vi.mock("@/app/(dashboard)/organizations/_components/organizations", () => ({
@ -135,7 +135,6 @@ vi.mock("@/app/(dashboard)/tag-management/_components", () => ({ default: stub("
vi.mock("@/app/(dashboard)/vector-stores/_components", () => ({ default: stub("vector-stores") }));
vi.mock("@/components/ui_theme_settings", () => ({ default: stub("ui-theme-settings") }));
vi.mock("@/components/organisms/create_key_button", () => ({ fetchUserModels: vi.fn() }));
vi.mock("@/components/common_components/fetch_teams", () => ({ fetchTeams: vi.fn() }));
vi.mock("@/components/ui/ui-loading-spinner", () => ({
UiLoadingSpinner: stub("spinner"),
}));
@ -270,9 +269,9 @@ describe("CreateKeyPage auth behavior", () => {
expect(window.location.replace).not.toHaveBeenCalled();
});
// And the default page content appears (UserDashboard stub; chrome now lives in the layout)
// And the default page content appears (ApiKeysDashboard stub; chrome now lives in the layout)
await waitFor(() => {
expect(screen.getByTestId("user-dashboard")).toBeInTheDocument();
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
});
});