refactor(ui): render the Virtual Keys page without the legacy user dashboard

The API Keys route mounted the pre-App-Router UserDashboard component,
whose beforeunload handler cleared sessionStorage on every refresh of
the Virtual Keys page. That wiped the Playground chat history and model,
the logs live-tail preference, and everything else other pages keep in
session storage. The same component also re-decoded the login token,
re-fetched teams, and wrote cache entries nothing read.

ApiKeysDashboard now renders VirtualKeysTable and the Create Key button
directly, taking identity and role from useAuthorized like every other
page. Create Key is hidden for view-only roles, which the proxy already
rejects on /key/generate. The legacy component, its test, the fetch_teams
helper, and their grandfathered eslint suppressions are removed, and the
ProxySettings type moves to useProxySettings.
This commit is contained in:
ryan-crabbe-berri 2026-09-05 17:49:29 -07:00
parent a9f8a8d794
commit da705c9475
10 changed files with 99 additions and 485 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;