mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
commit
09e9fd5f60
11 changed files with 102 additions and 489 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue