From 6db7001c05d7dd5653aa19ee3543fd3b4ccc4fd3 Mon Sep 17 00:00:00 2001 From: jesus-berri Date: Tue, 3 Nov 2026 13:41:20 -0800 Subject: [PATCH] feat(ui): show user names instead of raw ids on detail pages (#47181) * feat: show user names in access group details Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * 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> * fix(ui): look up user names by exact id Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): fall back to user list for org admins Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): fall back when /v2/user/info returns a different user id Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): let org admins read their org members in /v2/user/info Org admins could list a user through /user/list but got a 404 from /v2/user/info for that same user, so the dashboard fell back to a substring /user/list lookup that could miss the user. /v2/user/info now allows an org admin to read users in organizations they administer, and the dashboard's single-user lookup calls only that endpoint. Expected 4xx responses from /v2/user/info no longer log a traceback. * fix(proxy): drop unbounded IN from org admin check in /v2/user/info --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal_user_endpoints.py | 29 ++++- .../test_internal_user_endpoints.py | 112 ++++++++++++++++++ .../AccessGroupsDetailsPage.test.tsx | 63 ++++++++++ .../_components/AccessGroupsDetailsPage.tsx | 6 +- .../(dashboard)/hooks/users/useUsers.test.ts | 62 ++++++++-- .../app/(dashboard)/hooks/users/useUsers.ts | 15 ++- .../_components/ProjectDetailsPage.test.tsx | 32 ++++- .../_components/ProjectDetailsPage.tsx | 6 +- .../prompts/_components/prompt_info.test.tsx | 36 ++++++ .../prompts/_components/prompt_info.tsx | 5 +- .../model_info_view.integration.test.tsx | 24 ++++ .../src/components/model_info_view.tsx | 8 +- .../organization/organization_view.test.tsx | 34 ++++++ .../organization/organization_view.tsx | 3 +- .../src/components/shared/UserReference.tsx | 30 +++++ 15 files changed, 438 insertions(+), 27 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/UserReference.tsx diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 54a2d4062d2..d2aac9cae38 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -47,7 +47,7 @@ from litellm.proxy.auth.password_policy import ( ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast -from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for +from litellm.proxy.common_utils.openai_error_payload import openai_shaped_proxy_exception, proxy_exception_for from litellm.proxy.common_utils.user_api_key_cache import ( AUTH_OBJECTS_TARGET, object_permission_cache_key, @@ -1048,6 +1048,7 @@ async def _check_user_info_v2_access( 1. Proxy admins / proxy admin viewers can access any user 2. User can access their own info 3. Team admins can access info of users in their teams + 4. Org admins can access info of users in their organizations Raises on unexpected DB errors so they surface as 500s, not silent 404s. """ @@ -1090,9 +1091,33 @@ async def _check_user_info_v2_access( if team.team_id in (target_user.teams or []): return target_user + if user_api_key_dict.user_id is not None and await _target_in_caller_admin_org( + prisma_client=prisma_client, + caller_user_id=user_api_key_dict.user_id, + target_user_id=target_user_id, + ): + return await _fetch_target_user() + return None +async def _target_in_caller_admin_org( + prisma_client: "PrismaClient", + caller_user_id: str, + target_user_id: str, +) -> bool: + admin_memberships: Final = await prisma_client.db.litellm_organizationmembership.find_many( + where={"user_id": caller_user_id, "user_role": LitellmUserRoles.ORG_ADMIN.value} + ) + if not admin_memberships: + return False + admin_org_ids: Final = {membership.organization_id for membership in admin_memberships} + target_memberships: Final = await prisma_client.db.litellm_organizationmembership.find_many( + where={"user_id": target_user_id} + ) + return any(membership.organization_id in admin_org_ids for membership in target_memberships) + + @router.get( "/v2/user/info", tags=["Internal User management"], @@ -1197,6 +1222,8 @@ async def user_info_v2( ), ) except Exception as e: + if isinstance(e, HTTPException) and e.status_code < 500: + raise openai_shaped_proxy_exception(e, str(e.detail), e.status_code) verbose_proxy_logger.exception("litellm.proxy.proxy_server.user_info_v2(): Exception occured - %s", e) raise handle_exception_on_proxy(e) diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index 39586a1a204..d8aa0239e2d 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -3352,6 +3352,8 @@ async def test_user_info_v2_internal_user_cannot_query_other(mocker): mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique) + mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock(return_value=[]) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mock_request = mocker.MagicMock(spec=Request) @@ -3456,6 +3458,40 @@ async def test_user_info_v2_nonexistent_user_returns_404(mocker): assert "nonexistent-user-id" in str(exc_info.value.message) +@pytest.mark.parametrize( + ("find_unique", "expected_code", "logs_traceback"), + [ + (AsyncMock(return_value=None), "404", False), + (AsyncMock(side_effect=RuntimeError("db down")), "500", True), + ], +) +@pytest.mark.asyncio +async def test_user_info_v2_logs_traceback_only_for_server_errors( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + find_unique: AsyncMock, + expected_code: str, + logs_traceback: bool, +) -> None: + from fastapi import Request + + from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2 + + prisma: Final = mocker.MagicMock() + prisma.db.litellm_usertable.find_unique = find_unique + mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma) + + with caplog.at_level(logging.ERROR), pytest.raises(ProxyException) as exc_info: + await user_info_v2( + request=mocker.MagicMock(spec=Request), + user_id="some-user", + user_api_key_dict=UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert exc_info.value.code == expected_code + assert any(record.exc_info for record in caplog.records) is logs_traceback + + @pytest.mark.asyncio async def test_user_info_v2_response_shape(mocker): """ @@ -3674,6 +3710,8 @@ async def test_user_info_v2_team_admin_cannot_query_non_team_member(mocker): mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(side_effect=mock_find_many_teams) + mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock(return_value=[]) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mock_request = mocker.MagicMock(spec=Request) @@ -3690,6 +3728,80 @@ async def test_user_info_v2_team_admin_cannot_query_non_team_member(mocker): assert exc_info.value.code == "404" +_ORG_MEMBERSHIPS: Final = ( + SimpleNamespace(user_id="org-admin-user", organization_id="org-a", user_role="org_admin"), + SimpleNamespace(user_id="org-admin-user", organization_id="org-c", user_role="internal_user"), + SimpleNamespace(user_id="member-of-a", organization_id="org-a", user_role="internal_user"), + SimpleNamespace(user_id="member-of-b", organization_id="org-b", user_role="internal_user"), + SimpleNamespace(user_id="member-of-c", organization_id="org-c", user_role="internal_user"), +) + + +def _membership_matches(membership: SimpleNamespace, where: Mapping[str, object]) -> bool: + return membership.user_id == where["user_id"] and ( + "user_role" not in where or membership.user_role == where["user_role"] + ) + + +def _org_admin_prisma(mocker: MockerFixture) -> MagicMock: + def user_row(user_id: str) -> MagicMock: + row: Final = mocker.MagicMock() + row.teams = [] + row.model_dump.return_value = {"user_id": user_id, "user_email": f"{user_id}@example.com", "teams": []} + return row + + async def find_unique(**kwargs: dict[str, str]) -> MagicMock | None: + user_id: Final = kwargs["where"]["user_id"] + return user_row(user_id) if user_id in {m.user_id for m in _ORG_MEMBERSHIPS} else None + + async def find_many(**kwargs: Mapping[str, object]) -> list[SimpleNamespace]: + return [m for m in _ORG_MEMBERSHIPS if _membership_matches(m, kwargs["where"])] + + prisma: Final = mocker.MagicMock() + prisma.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=find_unique) + prisma.db.litellm_organizationmembership.find_many = mocker.AsyncMock(side_effect=find_many) + return prisma + + +@pytest.mark.asyncio +async def test_user_info_v2_org_admin_can_query_member_of_their_org(mocker: MockerFixture) -> None: + from fastapi import Request + + from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2 + + mocker.patch("litellm.proxy.proxy_server.prisma_client", _org_admin_prisma(mocker)) + + response: Final = await user_info_v2( + request=mocker.MagicMock(spec=Request), + user_id="member-of-a", + user_api_key_dict=UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER), + ) + + assert response.user_id == "member-of-a" + assert response.user_email == "member-of-a@example.com" + + +@pytest.mark.parametrize("target_user_id", ["member-of-b", "member-of-c"]) +@pytest.mark.asyncio +async def test_user_info_v2_org_admin_cannot_query_user_outside_their_admin_orgs( + mocker: MockerFixture, target_user_id: str +) -> None: + from fastapi import Request + + from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2 + + mocker.patch("litellm.proxy.proxy_server.prisma_client", _org_admin_prisma(mocker)) + + with pytest.raises(ProxyException) as exc_info: + await user_info_v2( + request=mocker.MagicMock(spec=Request), + user_id=target_user_id, + user_api_key_dict=UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER), + ) + + assert exc_info.value.code == "404" + + @pytest.mark.asyncio async def test_user_info_v2_url_encoding_plus_character(mocker): """ 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..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 @@ -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,64 @@ 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("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" }); + + 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 f0529121b21..cd8a0863e92 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 @@ -12,9 +12,9 @@ import { } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -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"; @@ -190,7 +190,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr {accessGroup.created_by && ( <> {t("accessGroups.detail.by")} - + )} @@ -200,7 +200,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr {accessGroup.updated_by && ( <> {t("accessGroups.detail.by")} - + )} 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..36f49615317 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,22 +318,47 @@ 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(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 proxy returns a different user than requested", async () => { - vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1)); + it("resolves to no user when user info returns a different user id", async () => { + vi.mocked(userGetInfoV2).mockResolvedValue(buildUserInfoV2Response("a+b")); - const { result } = renderHook(() => useUserLookup("someone-else"), { wrapper }); + const { result } = renderHook(() => useUserLookup("a b"), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toBeNull(); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("resolves to no user when the user info endpoint returns a 404", async () => { + 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).not.toHaveBeenCalled(); + }); + + 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); + expect(userListCall).not.toHaveBeenCalled(); }); it("does not query without a user id", async () => { @@ -322,7 +366,7 @@ describe("useUserLookup", () => { 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 +376,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..20df7388ca1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -1,8 +1,10 @@ -import { userListCall, UserInfo, UserListResponse } from "@/components/networking"; +import { userGetInfoV2, userListCall } from "@/components/networking"; +import type { 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"); @@ -58,11 +60,16 @@ export const useUserEmailLookup = (userIds: readonly string[]) => { export const useUserLookup = (userId: string | null) => { const { accessToken, userRole } = useAuthorized(); - return useQuery({ + 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 { + const user = await userGetInfoV2(accessToken!, userId!); + return user.user_id === userId ? user : null; + } 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/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 9a37f026c12..5a3c87bf97e 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 @@ -15,6 +15,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"; @@ -333,7 +334,9 @@ const PromptInfoView: React.FC = ({

{t("prompts.info.overview.createdBy")}

-

{promptData.created_by || "-"}

+

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

diff --git a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx index 32e590017b0..a42db06d251 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.integration.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("./model_info/KubernetesPodsCard", () => ({ default: ({ modelId }: { modelId: string }) =>
{modelId}
, })); @@ -49,6 +50,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(), @@ -107,6 +118,7 @@ describe("ModelInfoView", () => { }, }); vi.clearAllMocks(); + vi.mocked(useUserLookup).mockReturnValue({ data: null } as ReturnType); mockUsePtuCostAttributionEnabled.mockReturnValue(false); mockUseTeams.mockReturnValue({ data: undefined, isLoading: false, error: null }); @@ -1488,6 +1500,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 f1ff4534941..27f5198b1fd 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"; @@ -668,7 +669,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 54eb41d02aa..7c8e2cedbef 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"; @@ -197,7 +198,7 @@ const OrganizationInfoView: React.FC = ({

{t("organizations.view.overview.createdByLabel")} - {orgData.created_by} + {orgData.created_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..b67af776dcf --- /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?.trim() || user?.user_email?.trim() || null; + const href = userDetailHref(userId); + + if (!name) { + return {userId}; + } + + return ( + + {name} + + ); +}