mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
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>
This commit is contained in:
parent
883a2d1572
commit
6db7001c05
15 changed files with 438 additions and 27 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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<typeof useAccessGroupDetails>);
|
||||
mockUseUserLookup.mockReturnValue({ data: null } as ReturnType<typeof useUserLookup>);
|
||||
});
|
||||
|
||||
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<typeof useUserLookup>);
|
||||
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<typeof useUserLookup>);
|
||||
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<typeof useUserLookup>);
|
||||
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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
<span>{t("accessGroups.detail.by")}</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.created_by} />
|
||||
<UserReference userId={accessGroup.created_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
@ -200,7 +200,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
{accessGroup.updated_by && (
|
||||
<>
|
||||
<span>{t("accessGroups.detail.by")}</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
|
||||
<UserReference userId={accessGroup.updated_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<UserInfo | null>({
|
||||
return useQuery<UserInfoV2Response | null>({
|
||||
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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? <div data-testid="edit-modal" /> : null),
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
return { ...actual, useUserLookup: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
|
||||
default: ({ userId }: { userId: string }) => <span>{userId}</span>,
|
||||
vi.mock("./ProjectModals/EditProjectModal", () => ({
|
||||
EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div data-testid="edit-modal" /> : 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<typeof useUserLookup>);
|
||||
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<typeof useUserLookup>,
|
||||
);
|
||||
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
|
||||
|
||||
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
|
||||
expect(screen.getByRole("heading", { name: "My Project" })).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={project.created_by} />
|
||||
<UserReference userId={project.created_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
@ -126,7 +126,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
{project.updated_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<DefaultProxyAdminTag userId={project.updated_by} />
|
||||
<UserReference userId={project.updated_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
return { ...actual, useUserLookup: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockUseUserLookup = vi.mocked(useUserLookup);
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseUserLookup.mockReturnValue({ data: null } as ReturnType<typeof useUserLookup>);
|
||||
});
|
||||
|
||||
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<typeof useUserLookup>);
|
||||
vi.mocked(networking.getPromptInfo)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
...promptWithoutTemplate,
|
||||
prompt_spec: { ...promptWithoutTemplate.prompt_spec, created_by: "prompt-user-1" },
|
||||
});
|
||||
|
||||
render(<PromptInfoView promptId="support-reply" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=prompt-user-1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptInfoView code snippets", () => {
|
||||
|
|
|
|||
|
|
@ -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<PromptInfoProps> = ({
|
|||
<Card className="block p-6">
|
||||
<p>{t("prompts.info.overview.createdBy")}</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-sm font-medium">{promptData.created_by || "-"}</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{promptData.created_by ? <UserReference userId={promptData.created_by} /> : "-"}
|
||||
</h3>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => <div data-testid="kubernetes-pods-card">{modelId}</div>,
|
||||
}));
|
||||
|
|
@ -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<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
return { ...actual, useUserLookup: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("next/navigation")>()),
|
||||
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<typeof useUserLookup>);
|
||||
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<typeof useUserLookup>);
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Alice" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=123"),
|
||||
);
|
||||
});
|
||||
|
||||
describe("team alias", () => {
|
||||
const teamModel = {
|
||||
...defaultModelData,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</svg>
|
||||
Created By {modelData.model_info.created_by || "Not Set"}
|
||||
Created By{" "}
|
||||
{modelData.model_info.created_by ? (
|
||||
<UserReference userId={modelData.model_info.created_by} />
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/app/(dashboard)/hooks/users/useUsers")>();
|
||||
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<typeof useUserLookup>);
|
||||
});
|
||||
|
||||
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<typeof useOrganization>);
|
||||
mockUseUserLookup.mockReturnValue({
|
||||
data: { user_alias: "Alice", user_email: "alice@example.com" },
|
||||
} as ReturnType<typeof useUserLookup>);
|
||||
|
||||
renderWithProviders(
|
||||
<OrganizationInfoView
|
||||
organizationId="org_123"
|
||||
onClose={() => {}}
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OrganizationInfoProps> = ({
|
|||
</p>
|
||||
<p>
|
||||
{t("organizations.view.overview.createdByLabel")}
|
||||
{orgData.created_by}
|
||||
{orgData.created_by && <UserReference userId={orgData.created_by} />}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
30
ui/litellm-dashboard/src/components/shared/UserReference.tsx
Normal file
30
ui/litellm-dashboard/src/components/shared/UserReference.tsx
Normal file
|
|
@ -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 <DefaultProxyAdminTag userId={userId} />;
|
||||
}
|
||||
|
||||
const name = user?.user_alias?.trim() || user?.user_email?.trim() || null;
|
||||
const href = userDetailHref(userId);
|
||||
|
||||
if (!name) {
|
||||
return <EntityLink href={href}>{userId}</EntityLink>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip content={userId}>
|
||||
<EntityLink href={href}>{name}</EntityLink>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue