From f3a056bf97aac63f6fe8d7eb9c5bcd7c308ea4e4 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 17 Oct 2026 22:05:32 +0000 Subject: [PATCH 1/4] feat: show user names in access group details Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../AccessGroupsDetailsPage.test.tsx | 51 +++++++++++++++++++ .../_components/AccessGroupsDetailsPage.tsx | 6 +-- .../src/components/shared/UserReference.tsx | 30 +++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/UserReference.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx index a4a615fe798..13337584678 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx @@ -1,5 +1,7 @@ import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; +import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; +import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,6 +9,7 @@ import { renderWithProviders } from "../../../../../tests/test-utils"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"); +vi.mock("@/app/(dashboard)/hooks/users/useUsers"); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({ AccessGroupEditModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) => @@ -18,6 +21,7 @@ vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({ })); const mockUseAccessGroupDetails = vi.mocked(useAccessGroupDetails); +const mockUseUserLookup = vi.mocked(useUserLookup); const baseMockReturnValue = { data: undefined, @@ -89,6 +93,7 @@ describe("AccessGroupDetail", () => { ...baseMockReturnValue, data: createMockAccessGroup(), } as ReturnType); + mockUseUserLookup.mockReturnValue({ data: null } as ReturnType); }); it("should render the component", () => { @@ -144,6 +149,52 @@ describe("AccessGroupDetail", () => { expect(screen.getByText("A test access group")).toBeInTheDocument(); }); + describe("user references", () => { + it("shows a user's alias as a linked name and reveals the id in a tooltip", async () => { + const user = userEvent.setup(); + mockUseUserLookup.mockReturnValue({ + data: { user_alias: "Alice", user_email: "alice@example.com" }, + } as ReturnType); + renderWith({ created_by: "user-123" }); + + const link = screen.getByRole("link", { name: "Alice" }); + expect(link).toHaveAttribute("href", expect.stringContaining("/users?user=user-123")); + expect(screen.queryByText("user-123")).not.toBeInTheDocument(); + + await user.hover(link); + + expect(await screen.findByText("user-123")).toBeInTheDocument(); + }); + + it("shows the email when the user has no alias", () => { + mockUseUserLookup.mockReturnValue({ + data: { user_alias: null, user_email: "alice@example.com" }, + } as ReturnType); + renderWith({ updated_by: "user-456" }); + + expect(screen.getByRole("link", { name: "alice@example.com" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=user-456"), + ); + }); + + it("links the user id when lookup has no user details", () => { + renderWith({ updated_by: "missing-user" }); + + expect(screen.getByRole("link", { name: "missing-user" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=missing-user"), + ); + }); + + it("shows the default admin badge without looking up its user id", () => { + renderWith({ created_by: DEFAULT_PROXY_ADMIN_USER_ID }); + + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); + expect(mockUseUserLookup).toHaveBeenCalledWith(null); + }); + }); + it("should display em dash when description is empty", () => { renderWith({ description: null }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 11d75c0f750..63d3e67e836 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -10,9 +10,9 @@ import { UsersIcon, } from "lucide-react"; import { useState } from "react"; -import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; import { BadgeLink } from "@/components/shared/BadgeLink"; import CopyButton from "@/components/shared/CopyButton"; +import { UserReference } from "@/components/shared/UserReference"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -177,7 +177,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr {accessGroup.created_by && ( <> by - + )} @@ -187,7 +187,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr {accessGroup.updated_by && ( <> by - + )} diff --git a/ui/litellm-dashboard/src/components/shared/UserReference.tsx b/ui/litellm-dashboard/src/components/shared/UserReference.tsx new file mode 100644 index 00000000000..7387e153f0b --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/UserReference.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; +import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import { EntityLink } from "@/components/shared/EntityLink"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels"; +import { userDetailHref } from "@/utils/entityLinks"; + +export function UserReference({ userId }: { userId: string }) { + const isDefaultAdmin = userId === DEFAULT_PROXY_ADMIN_USER_ID; + const { data: user } = useUserLookup(isDefaultAdmin ? null : userId); + + if (isDefaultAdmin) { + return ; + } + + const name = user?.user_alias || user?.user_email || null; + const href = userDetailHref(userId); + + if (!name) { + return {userId}; + } + + return ( + + {name} + + ); +} From 856a302d79d738f12c146fa7dee7e08e7f355913 Mon Sep 17 00:00:00 2001 From: jesus Date: Sat, 17 Oct 2026 22:12:30 +0000 Subject: [PATCH 2/4] feat: show user names on project, org, model and prompt details Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_components/ProjectDetailsPage.test.tsx | 32 ++++++++++++++--- .../_components/ProjectDetailsPage.tsx | 6 ++-- .../prompts/_components/prompt_info.test.tsx | 36 +++++++++++++++++++ .../prompts/_components/prompt_info.tsx | 5 ++- .../src/components/model_info_view.test.tsx | 24 +++++++++++++ .../src/components/model_info_view.tsx | 8 ++++- .../organization/organization_view.test.tsx | 34 ++++++++++++++++++ .../organization/organization_view.tsx | 3 +- 8 files changed, 138 insertions(+), 10 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx index 5d84488d739..abe942743d5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen } from "../../../../../tests/test-utils"; import { ProjectDetail } from "./ProjectDetailsPage"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; const mockUseProjectDetails = vi.fn(); vi.mock("@/app/(dashboard)/hooks/projects/useProjectDetails", () => ({ @@ -24,12 +25,18 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: () => mockUseUISettings(), })); -vi.mock("./ProjectModals/EditProjectModal", () => ({ - EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ?
: null), +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useUserLookup: vi.fn() }; +}); + +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: vi.fn() }), })); -vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({ - default: ({ userId }: { userId: string }) => {userId}, +vi.mock("./ProjectModals/EditProjectModal", () => ({ + EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ?
: null), })); vi.mock("./ProjectKeysSection", () => ({ @@ -69,9 +76,11 @@ const yAxisTickLabels = (container: HTMLElement) => describe("ProjectDetail", () => { const onBack = vi.fn(); + const mockUseUserLookup = vi.mocked(useUserLookup); beforeEach(() => { vi.clearAllMocks(); + mockUseUserLookup.mockReturnValue({ data: null } as ReturnType); mockUseTeam.mockReturnValue({ data: undefined, isLoading: false }); mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false }); mockUseUISettings.mockReturnValue({ data: { values: {} } }); @@ -113,6 +122,21 @@ describe("ProjectDetail", () => { expect(screen.getByText("My Project")).toBeInTheDocument(); }); + it("shows the creator alias as a link to the user page", () => { + mockUseUserLookup.mockImplementation( + (userId) => + ({ + data: userId === "user-1" ? { user_alias: "Alice", user_email: "alice@example.com" } : null, + }) as ReturnType, + ); + renderWithProviders(); + + expect(screen.getByRole("link", { name: "Alice" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=user-1"), + ); + }); + it("should display the project alias as the page title", () => { renderWithProviders(); expect(screen.getByRole("heading", { name: "My Project" })).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index b9d8ef94cb4..22e4f08d683 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -6,8 +6,8 @@ import { isUserTeamAdminForSingleTeam } from "@/utils/roles"; import { BarChart } from "@/components/shared/charts"; import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; import CopyButton from "@/components/shared/CopyButton"; +import { UserReference } from "@/components/shared/UserReference"; import { StatusBadge } from "@/components/shared/table_cells/status_badge"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -116,7 +116,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { {project.created_by && ( <> by - + )} @@ -126,7 +126,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { {project.updated_by && ( <> by - + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx index b14d5c3d91f..15c93882817 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import PromptInfoView from "./prompt_info"; import * as networking from "@/components/networking"; +import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; vi.mock("@/components/networking", () => ({ getPromptInfo: vi.fn(), @@ -17,6 +18,22 @@ vi.mock("./prompt_editor_view/PromptCodeSnippets", () => ({ ), })); +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useUserLookup: vi.fn() }; +}); + +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: vi.fn() }), +})); + +const mockUseUserLookup = vi.mocked(useUserLookup); + +beforeEach(() => { + mockUseUserLookup.mockReturnValue({ data: null } as ReturnType); +}); + const promptWithoutTemplate = { prompt_spec: { prompt_id: "support-reply", @@ -58,6 +75,25 @@ describe("PromptInfoView environment scoping", () => { await screen.findByRole("tab", { name: "Raw JSON" }); expect(networking.getPromptInfo).toHaveBeenCalledWith("sk-test", "support-reply", undefined); }); + + it("shows the creator alias as a link to the user page", async () => { + mockUseUserLookup.mockReturnValue({ + data: { user_alias: "Alice", user_email: "alice@example.com" }, + } as ReturnType); + vi.mocked(networking.getPromptInfo) + .mockReset() + .mockResolvedValue({ + ...promptWithoutTemplate, + prompt_spec: { ...promptWithoutTemplate.prompt_spec, created_by: "prompt-user-1" }, + }); + + render(); + + expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=prompt-user-1"), + ); + }); }); describe("PromptInfoView code snippets", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx index 062e1d84a0a..b9ffad96192 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx @@ -14,6 +14,7 @@ import { import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { ArrowLeft, CheckIcon, CopyIcon, Pencil, Trash2 } from "lucide-react"; import { toast } from "@/lib/toast"; +import { UserReference } from "@/components/shared/UserReference"; import PromptCodeSnippets from "./prompt_editor_view/PromptCodeSnippets"; import { extractModel, extractTemplateVariables, getBasePromptId, getCurrentVersion } from "./prompt_utils"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -329,7 +330,9 @@ const PromptInfoView: React.FC = ({

Created By

-

{promptData.created_by || "-"}

+

+ {promptData.created_by ? : "-"} +

diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 6e7bc9a86d7..eeeea1ebc05 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import ModelInfoView from "./model_info_view"; import { toast } from "@/lib/toast"; import * as networking from "./networking"; +import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; vi.mock( "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults", async () => await import("../../tests/mocks/complexityScorerDefaults"), @@ -47,6 +48,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => mockUseTeams(), })); +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useUserLookup: vi.fn() }; +}); + +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: vi.fn() }), +})); + const mockUsePtuCostAttributionEnabled = vi.fn(); vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () => ({ usePtuCostAttributionEnabled: () => mockUsePtuCostAttributionEnabled(), @@ -106,6 +117,7 @@ describe("ModelInfoView", () => { }, }); vi.clearAllMocks(); + vi.mocked(useUserLookup).mockReturnValue({ data: null } as ReturnType); mockUsePtuCostAttributionEnabled.mockReturnValue(false); mockUseTeams.mockReturnValue({ data: undefined, isLoading: false, error: null }); @@ -1371,6 +1383,18 @@ describe("ModelInfoView", () => { }); }); + it("shows the creator alias as a link to the user page", async () => { + vi.mocked(useUserLookup).mockReturnValue({ + data: { user_alias: "Alice", user_email: "alice@example.com" }, + } as ReturnType); + render(, { wrapper }); + + expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=123"), + ); + }); + describe("team alias", () => { const teamModel = { ...defaultModelData, diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index ae34b17d0e8..1aacbf490af 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SimpleTooltip } from "@/components/ui/tooltip"; +import { UserReference } from "@/components/shared/UserReference"; import { applyPtuModelInfo } from "../utils/ptuModelInfo"; import { usePtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled"; import { ArrowLeft, CheckIcon, CopyIcon, Info } from "lucide-react"; @@ -676,7 +677,12 @@ export default function ModelInfoView({ d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> - Created By {modelData.model_info.created_by || "Not Set"} + Created By{" "} + {modelData.model_info.created_by ? ( + + ) : ( + "Not Set" + )}
diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index 88d98fac60e..c01e6769fdd 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -7,6 +7,7 @@ import { vi, test, expect, beforeEach, describe, type Mock } from "vitest"; import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import OrganizationInfoView from "./organization_view"; import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), @@ -37,7 +38,13 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ }, })); +vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useUserLookup: vi.fn() }; +}); + const mockUseOrganization = vi.mocked(useOrganization); +const mockUseUserLookup = vi.mocked(useUserLookup); // Mock noisy/heavy child components to keep this test focused on render vi.mock("../object_permissions_view", () => ({ @@ -103,6 +110,7 @@ const mockOrg = { beforeEach(() => { mockUseOrganization.mockReset(); + mockUseUserLookup.mockReturnValue({ data: null } as ReturnType); }); test("renders organization view after loading data", async () => { @@ -123,6 +131,32 @@ test("renders organization view after loading data", async () => { expect(orgName).toBeInTheDocument(); }); +test("shows the creator alias as a link to the user page", async () => { + mockUseOrganization.mockReturnValue({ + data: { ...mockOrg, created_by: "org-user-1" }, + isLoading: false, + } as unknown as ReturnType); + mockUseUserLookup.mockReturnValue({ + data: { user_alias: "Alice", user_email: "alice@example.com" }, + } as ReturnType); + + renderWithProviders( + {}} + accessToken="test-token" + is_org_admin={false} + is_proxy_admin={false} + userModels={[]} + />, + ); + + expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=org-user-1"), + ); +}); + test("should display empty state when organization has no members", async () => { mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index f325e92d2b5..3848ee1ea20 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -5,6 +5,7 @@ import { useUrlTab } from "@/hooks/useUrlTab"; import { useVisitedTabs } from "@/hooks/useVisitedTabs"; import { MoneyCell } from "@/components/shared/table_cells"; import CopyButton from "@/components/shared/CopyButton"; +import { UserReference } from "@/components/shared/UserReference"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -186,7 +187,7 @@ const OrganizationInfoView: React.FC = ({

Created: {new Date(orgData.created_at).toLocaleDateString()}

Updated: {new Date(orgData.updated_at).toLocaleDateString()}

-

Created By: {orgData.created_by}

+

Created By: {orgData.created_by && }

From dce0c246293940b80e04fc46e723021236194d70 Mon Sep 17 00:00:00 2001 From: jesus Date: Sun, 18 Oct 2026 00:30:00 +0000 Subject: [PATCH 3/4] fix(ui): look up user names by exact id Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../AccessGroupsDetailsPage.test.tsx | 12 +++++ .../(dashboard)/hooks/users/useUsers.test.ts | 47 +++++++++++++++---- .../app/(dashboard)/hooks/users/useUsers.ts | 41 ++++++++++++++-- .../src/components/shared/UserReference.tsx | 2 +- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx index 13337584678..f3d9b67eb4b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx @@ -178,6 +178,18 @@ describe("AccessGroupDetail", () => { ); }); + it("falls back to the email when the alias contains only whitespace", () => { + mockUseUserLookup.mockReturnValue({ + data: { user_alias: " ", user_email: "alice@example.com" }, + } as ReturnType); + renderWith({ created_by: "user-789" }); + + expect(screen.getByRole("link", { name: "alice@example.com" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=user-789"), + ); + }); + it("links the user id when lookup has no user details", () => { renderWith({ updated_by: "missing-user" }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts index f49c728446b..242f8967664 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -3,10 +3,12 @@ import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React, { ReactNode } from "react"; import { useInfiniteUsers, useUserEmailLookup, useUserLookup } from "./useUsers"; -import { userListCall } from "@/components/networking"; -import type { UserListResponse } from "@/components/networking"; +import { userGetInfoV2, userListCall } from "@/components/networking"; +import type { UserInfoV2Response, UserListResponse } from "@/components/networking"; +import { ApiError } from "@/lib/http/client"; vi.mock("@/components/networking", () => ({ + userGetInfoV2: vi.fn(), userListCall: vi.fn(), })); @@ -56,6 +58,23 @@ const buildUserListResponse = (page: number, totalPages: number, userCount = 2): })), }); +const buildUserInfoV2Response = (userId: string): UserInfoV2Response => ({ + user_id: userId, + user_email: "alice@example.com", + user_alias: "Alice", + user_role: "Internal User", + spend: 0, + max_budget: null, + models: [], + budget_duration: null, + budget_reset_at: null, + metadata: null, + created_at: null, + updated_at: null, + sso_user_id: null, + teams: [], +}); + describe("useInfiniteUsers", () => { let queryClient: QueryClient; @@ -299,17 +318,19 @@ describe("useUserLookup", () => { React.createElement(QueryClientProvider, { client: queryClient }, children); it("fetches exactly the requested user by id", async () => { - vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1)); + const exactUser = buildUserInfoV2Response("user-1-0"); + vi.mocked(userGetInfoV2).mockResolvedValue(exactUser); const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(userListCall).toHaveBeenCalledWith("test-access-token", ["user-1-0"], 1, 1); + expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token", "user-1-0"); + expect(result.current.data).toMatchObject(exactUser); expect(result.current.data?.user_id).toBe("user-1-0"); }); - it("resolves to null when the proxy returns a different user than requested", async () => { - vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1)); + it("resolves to null when the user info endpoint returns a 404", async () => { + vi.mocked(userGetInfoV2).mockRejectedValue(new ApiError("Not Found", 404, {})); const { result } = renderHook(() => useUserLookup("someone-else"), { wrapper }); @@ -317,12 +338,22 @@ describe("useUserLookup", () => { expect(result.current.data).toBeNull(); }); + it("rethrows errors other than 404", async () => { + const serverError = new ApiError("Internal Server Error", 500, {}); + vi.mocked(userGetInfoV2).mockRejectedValue(serverError); + + const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBe(serverError); + }); + it("does not query without a user id", async () => { const { result } = renderHook(() => useUserLookup(null), { wrapper }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(result.current.fetchStatus).toBe("idle"); - expect(userListCall).not.toHaveBeenCalled(); + expect(userGetInfoV2).not.toHaveBeenCalled(); }); it("does not query for a non-admin role", async () => { @@ -332,7 +363,7 @@ describe("useUserLookup", () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(result.current.fetchStatus).toBe("idle"); - expect(userListCall).not.toHaveBeenCalled(); + expect(userGetInfoV2).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts index 5bfbbc76e9a..bfcf982bbc2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -1,14 +1,43 @@ -import { userListCall, UserInfo, UserListResponse } from "@/components/networking"; +import { userGetInfoV2, userListCall } from "@/components/networking"; +import type { UserInfo, UserInfoV2Response, UserListResponse } from "@/components/networking"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { canListUsers } from "@/utils/roles"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { ApiError } from "@/lib/http/client"; const infiniteUsersKeys = createQueryKeys("infiniteUsers"); const userLookupKeys = createQueryKeys("userLookup"); const DEFAULT_PAGE_SIZE = 50; +const mapUserInfoV2ToUserInfo = (user: UserInfoV2Response): UserInfo => { + const userInfo: UserInfo = { + user_id: user.user_id, + user_email: user.user_email, + user_alias: user.user_alias, + user_role: user.user_role, + spend: user.spend, + max_budget: user.max_budget, + models: user.models, + budget_duration: user.budget_duration, + budget_reset_at: user.budget_reset_at, + metadata: user.metadata, + created_at: user.created_at, + updated_at: user.updated_at, + sso_user_id: user.sso_user_id, + teams: user.teams, + tpm_limit: user.tpm_limit, + rpm_limit: user.rpm_limit, + allowed_cache_controls: [], + key_count: 0, + model_max_budget: null, + model_spend: null, + policies: [], + }; + return userInfo; +}; + export const useInfiniteUsers = (pageSize: number = DEFAULT_PAGE_SIZE, searchEmail?: string) => { const { accessToken, userRole } = useAuthorized(); return useInfiniteQuery({ @@ -61,8 +90,14 @@ export const useUserLookup = (userId: string | null) => { return useQuery({ queryKey: userLookupKeys.detail(userId ?? ""), queryFn: async () => { - const response = await userListCall(accessToken!, [userId!], 1, 1); - return response.users.find((user) => user.user_id === userId) ?? null; + try { + return mapUserInfoV2ToUserInfo(await userGetInfoV2(accessToken!, userId!)); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return null; + } + throw error; + } }, enabled: Boolean(accessToken) && Boolean(userId) && canListUsers(userRole), }); diff --git a/ui/litellm-dashboard/src/components/shared/UserReference.tsx b/ui/litellm-dashboard/src/components/shared/UserReference.tsx index 7387e153f0b..b67af776dcf 100644 --- a/ui/litellm-dashboard/src/components/shared/UserReference.tsx +++ b/ui/litellm-dashboard/src/components/shared/UserReference.tsx @@ -15,7 +15,7 @@ export function UserReference({ userId }: { userId: string }) { return ; } - const name = user?.user_alias || user?.user_email || null; + const name = user?.user_alias?.trim() || user?.user_email?.trim() || null; const href = userDetailHref(userId); if (!name) { From 6cb66cd4cb3d4918b094e350848273feae6ddf67 Mon Sep 17 00:00:00 2001 From: jesus Date: Sun, 18 Oct 2026 00:34:25 +0000 Subject: [PATCH 4/4] fix(ui): fall back to user list for org admins Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../(dashboard)/hooks/users/useUsers.test.ts | 30 +++++++++++++++++-- .../app/(dashboard)/hooks/users/useUsers.ts | 3 +- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts index 242f8967664..70b818a8be4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -325,17 +325,42 @@ describe("useUserLookup", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token", "user-1-0"); + expect(userListCall).not.toHaveBeenCalled(); expect(result.current.data).toMatchObject(exactUser); expect(result.current.data?.user_id).toBe("user-1-0"); }); - it("resolves to null when the user info endpoint returns a 404", async () => { + it("falls back to the exact user list match when the user info endpoint returns a 404", async () => { + const response = buildUserListResponse(1, 1); + const exactUser = { ...response.users[1], user_id: "bob", user_alias: "Bob" }; + vi.mocked(userListCall).mockResolvedValue({ + ...response, + page_size: 100, + users: [{ ...response.users[0], user_id: "bob2" }, exactUser], + }); vi.mocked(userGetInfoV2).mockRejectedValue(new ApiError("Not Found", 404, {})); - const { result } = renderHook(() => useUserLookup("someone-else"), { wrapper }); + const { result } = renderHook(() => useUserLookup("bob"), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toMatchObject(exactUser); + expect(userListCall).toHaveBeenCalledWith("test-access-token", ["bob"], 1, 100); + }); + + it("resolves to null when the user list contains only a substring match", async () => { + const response = buildUserListResponse(1, 1); + vi.mocked(userListCall).mockResolvedValue({ + ...response, + page_size: 100, + users: [{ ...response.users[0], user_id: "bob2" }], + }); + vi.mocked(userGetInfoV2).mockRejectedValue(new ApiError("Not Found", 404, {})); + + const { result } = renderHook(() => useUserLookup("bob"), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toBeNull(); + expect(userListCall).toHaveBeenCalledWith("test-access-token", ["bob"], 1, 100); }); it("rethrows errors other than 404", async () => { @@ -346,6 +371,7 @@ describe("useUserLookup", () => { await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toBe(serverError); + expect(userListCall).not.toHaveBeenCalled(); }); it("does not query without a user id", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts index bfcf982bbc2..86e1276fee4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -94,7 +94,8 @@ export const useUserLookup = (userId: string | null) => { return mapUserInfoV2ToUserInfo(await userGetInfoV2(accessToken!, userId!)); } catch (error) { if (error instanceof ApiError && error.status === 404) { - return null; + const response = await userListCall(accessToken!, [userId!], 1, USER_LIST_MAX_PAGE_SIZE); + return response.users.find((user) => user.user_id === userId) ?? null; } throw error; }