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