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:
jesus-berri 2026-11-03 13:41:20 -08:00 • committed by GitHub
parent 883a2d1572
commit 6db7001c05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 438 additions and 27 deletions

View file

@ -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)

View file

@ -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):
"""

View file

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

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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),
});

View file

@ -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();

View file

@ -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>

View file

@ -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", () => {

View file

@ -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>

View file

@ -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,

View file

@ -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>

View file

@ -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);

View file

@ -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>

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