This commit is contained in:
jesus-berri 2026-10-11 16:20:05 +00:00 • committed by GitHub
commit 455b05b7ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1152 additions and 120 deletions

View file

@ -856,6 +856,16 @@
],
"title": "Created By"
},
"created_by_user": {
"anyOf": [
{
"$ref": "#/components/schemas/AccessGroupResource"
},
{
"type": "null"
}
]
},
"description": {
"anyOf": [
{
@ -882,6 +892,16 @@
}
],
"title": "Updated By"
},
"updated_by_user": {
"anyOf": [
{
"$ref": "#/components/schemas/AccessGroupResource"
},
{
"type": "null"
}
]
}
},
"required": [

View file

@ -35,6 +35,7 @@ from litellm.proxy.management_helpers.resource_display_names import (
agent_display_names,
key_display_names,
mcp_server_display_names,
user_display_names,
)
from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw
from litellm.repositories.table_repositories import AccessGroupRepository, TeamRepository
@ -66,6 +67,12 @@ class _AccessGroupRecord(Protocol):
@property
def assigned_key_ids(self) -> Sequence[str] | None: ...
@property
def created_by(self) -> str | None: ...
@property
def updated_by(self) -> str | None: ...
def dict(self) -> Mapping[str, object]: ...
@ -185,12 +192,19 @@ class _ResourceNames:
agents: Mapping[str, str]
teams: Mapping[str, str | None]
keys: Mapping[str, str]
users: Mapping[str, str]
def _label(ids: Sequence[str], names: Mapping[str, str | None]) -> tuple[AccessGroupResource, ...]:
return tuple(AccessGroupResource(id=resource_id, name=names.get(resource_id)) for resource_id in ids)
def _actor_resource(user_id: str | None, names: Mapping[str, str]) -> AccessGroupResource | None:
if user_id is None:
return None
return AccessGroupResource(id=user_id, name=names.get(user_id))
def _record_to_response(
record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str], names: _ResourceNames
) -> AccessGroupResponse:
@ -202,6 +216,8 @@ def _record_to_response(
"access_agents": _label(record.access_agent_ids or (), names.agents),
"assigned_teams": _label(assigned_team_ids, names.teams),
"assigned_keys": _label(record.assigned_key_ids or (), names.keys),
"created_by_user": _actor_resource(record.created_by, names.users),
"updated_by_user": _actor_resource(record.updated_by, names.users),
}
)
return AccessGroupResponse.model_validate(payload)
@ -219,7 +235,7 @@ async def _responses_for(
if not records:
return ()
teams: Final = await _teams_touching(TeamRepository(prisma_client).table, records)
mcp_servers, agents, keys = await asyncio.gather(
mcp_servers, agents, keys, users = await asyncio.gather(
mcp_server_display_names(
prisma_client,
_ids_across(records, lambda record: record.access_mcp_server_ids),
@ -229,12 +245,19 @@ async def _responses_for(
prisma_client, _ids_across(records, lambda record: record.access_agent_ids), global_agent_registry
),
key_display_names(prisma_client, _ids_across(records, lambda record: record.assigned_key_ids)),
user_display_names(
prisma_client,
_ids_across(
records, lambda record: tuple(user_id for user_id in (record.created_by, record.updated_by) if user_id)
),
),
)
names: Final = _ResourceNames(
mcp_servers=mcp_servers,
agents=agents,
teams=MappingProxyType({team.team_id: team.team_alias for team in teams}),
keys=keys,
users=users,
)
attached: Final = _attached_team_ids_by_group(records, teams)
return tuple(

View file

@ -6,7 +6,9 @@ from typing import Final
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
from litellm.proxy.utils import PrismaClient
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.table_repositories import AgentsRepository, MCPServerRepository
from litellm.repositories.user_repository import UserRepository
from litellm.repositories.verification_token_repository import VerificationTokenRepository
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -59,3 +61,11 @@ async def key_display_names(prisma_client: PrismaClient, tokens: Sequence[str])
where: Final = {"token": {"in": tuple(frozenset(tokens))}}
rows: Final = await VerificationTokenRepository(prisma_client).table.find_many(where=where)
return MappingProxyType({row.token: row.key_alias for row in rows if row.key_alias})
async def user_display_names(prisma_client: PrismaClient, user_ids: Sequence[str]) -> Mapping[str, str]:
"""user_id -> user_alias, else user_email, for the users that have either."""
if not user_ids:
return MappingProxyType({})
rows: Final = await find_many_in(UserRepository(prisma_client).table, "user_id", frozenset(user_ids))
return MappingProxyType({row.user_id: name for row in rows if (name := row.user_alias or row.user_email)})

View file

@ -45,5 +45,7 @@ class AccessGroupResponse(LiteLLMBaseModel):
assigned_keys: tuple[AccessGroupResource, ...]
created_at: datetime
created_by: str | None = None
created_by_user: AccessGroupResource | None = None
updated_at: datetime
updated_by: str | None = None
updated_by_user: AccessGroupResource | None = None

View file

@ -0,0 +1,261 @@
import uuid
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from typing import Final
import pytest
from tests.integration._support.client import Gateway, object_value, string_value
from tests.integration._support.database import write_rows
FALLBACK_EMAIL: Final = f"fallback-{uuid.uuid4().hex}@example.com"
@contextmanager
def _access_group(gateway: Gateway, body: dict[str, object], *, key: str) -> Iterator[str]:
created: Final = gateway.request("POST", "/v1/access_group", body, key=key)
assert created.status_code == 201, created.text
identity: Final = string_value(created.json()["access_group_id"])
try:
yield identity
finally:
deleted: Final = gateway.request("DELETE", f"/v1/access_group/{identity}")
assert deleted.status_code == 204, deleted.text
def test_access_group_detail_names_the_user_who_created_and_updated_it(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
team_id: Final = scenario.team(team_alias="Review Team")
user_id: Final = scenario.user(
user_alias="Ada Reviewer",
user_email=f"ada-{uuid.uuid4().hex}@example.com",
user_role="proxy_admin",
)
key: Final = scenario.key(user_id=user_id)
with _access_group(
gateway,
{
"access_group_name": f"integration-{uuid.uuid4().hex}",
"access_model_names": [],
"assigned_team_ids": [team_id],
},
key=key,
) as group_id:
updated: Final = gateway.request(
"PUT",
f"/v1/access_group/{group_id}",
{"description": "updated description"},
key=key,
)
assert updated.status_code == 200, updated.text
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text
group: Final = object_value(detail.json())
assert group["created_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, detail.text
assert group["updated_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, detail.text
assert group["assigned_teams"] == [{"id": team_id, "name": "Review Team"}], detail.text
listing: Final = gateway.request("GET", "/v1/access_group")
assert listing.status_code == 200, listing.text
entries: Final = listing.json()
assert isinstance(entries, list), listing.text
listed: Final = object_value(next(entry for entry in entries if entry["access_group_id"] == group_id))
assert listed["created_by_user"] == {"id": user_id, "name": "Ada Reviewer"}, listing.text
@pytest.mark.parametrize(
("user_fields", "expected_name"),
(
pytest.param({"user_email": FALLBACK_EMAIL}, FALLBACK_EMAIL, id="email"),
pytest.param({}, None, id="neither"),
),
)
def test_access_group_creator_without_alias_falls_back_to_email_then_none(
gateway: Gateway, user_fields: dict[str, str], expected_name: str | None
) -> None:
with gateway.scenario() as scenario:
user_id: Final = scenario.user(user_role="proxy_admin", **user_fields)
key: Final = scenario.key(user_id=user_id)
with _access_group(
gateway,
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
key=key,
) as group_id:
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text
assert object_value(detail.json())["created_by_user"] == {
"id": user_id,
"name": expected_name,
}, detail.text
def test_access_group_created_by_master_key_names_the_default_admin(gateway: Gateway) -> None:
with _access_group(
gateway,
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
key=gateway.key,
) as group_id:
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text
assert object_value(detail.json())["created_by_user"] == {
"id": "default_user_id",
"name": None,
}, detail.text
def test_access_group_creator_with_blank_alias_falls_back_to_email(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
email: Final = f"blank-alias-{uuid.uuid4().hex}@example.com"
user_id: Final = scenario.user(user_role="proxy_admin", user_alias="", user_email=email)
key: Final = scenario.key(user_id=user_id)
with _access_group(
gateway,
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
key=key,
) as group_id:
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text
assert object_value(detail.json())["created_by_user"] == {"id": user_id, "name": email}, detail.text
def test_access_group_update_by_another_user_keeps_creator_and_names_updater(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
creator_id: Final = scenario.user(user_alias="Creator One", user_role="proxy_admin")
creator_key: Final = scenario.key(user_id=creator_id)
updater_id: Final = scenario.user(
user_alias="Updater Two", user_email=f"updater-{uuid.uuid4().hex}@example.com", user_role="proxy_admin"
)
updater_key: Final = scenario.key(user_id=updater_id)
with _access_group(
gateway,
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
key=creator_key,
) as group_id:
updated: Final = gateway.request(
"PUT", f"/v1/access_group/{group_id}", {"description": "by another"}, key=updater_key
)
assert updated.status_code == 200, updated.text
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text
group: Final = object_value(detail.json())
assert group["created_by_user"] == {"id": creator_id, "name": "Creator One"}, detail.text
assert group["updated_by_user"] == {"id": updater_id, "name": "Updater Two"}, detail.text
def test_access_group_creator_deleted_afterwards_resolves_name_to_none(gateway: Gateway) -> None:
user_id: Final = f"del-{uuid.uuid4().hex}"
created_user: Final = gateway.request(
"POST", "/user/new", {"user_id": user_id, "user_alias": "Soon Deleted", "user_role": "proxy_admin"}
)
assert created_user.status_code == 200, created_user.text
minted: Final = gateway.request("POST", "/key/generate", {"user_id": user_id})
assert minted.status_code == 200, minted.text
key: Final = string_value(minted.json()["key"])
with _access_group(
gateway,
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
key=key,
) as group_id:
deleted: Final = gateway.request("POST", "/user/delete", {"user_ids": [user_id]})
assert deleted.status_code == 200 and deleted.json() == 1, deleted.text
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text
assert object_value(detail.json())["created_by_user"] == {
"id": user_id,
"name": None,
}, detail.text
def test_access_group_name_reflects_the_current_alias_after_a_rename(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
user_id: Final = scenario.user(user_alias="Old Alias", user_role="proxy_admin")
key: Final = scenario.key(user_id=user_id)
with _access_group(
gateway,
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
key=key,
) as group_id:
renamed: Final = gateway.request("POST", "/user/update", {"user_id": user_id, "user_alias": "New Alias"})
assert renamed.status_code == 200, renamed.text
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text
assert object_value(detail.json())["created_by_user"] == {
"id": user_id,
"name": "New Alias",
}, detail.text
def test_access_group_list_resolves_names_past_the_in_chunk_size(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
chunk_size: Final = 5000
write_rows(
f'INSERT INTO "LiteLLM_UserTable" (user_id, user_alias) '
f"SELECT 'chunk-{marker}-' || i, 'ChunkUser-' || i FROM generate_series(1, {chunk_size + 1}) AS i",
(),
)
write_rows(
f'INSERT INTO "LiteLLM_AccessGroupTable" (access_group_id, access_group_name, created_by) '
f"SELECT gen_random_uuid(), 'chunkgrp-{marker}-' || i, 'chunk-{marker}-' || i "
f"FROM generate_series(1, {chunk_size + 1}) AS i",
(),
)
try:
listing: Final = gateway.request("GET", "/v1/access_group")
assert listing.status_code == 200, listing.text
entries: Final = listing.json()
assert isinstance(entries, list), listing.text
seeded: Final = [e for e in entries if str(e.get("access_group_name", "")).startswith(f"chunkgrp-{marker}-")]
assert len(seeded) == chunk_size + 1
for entry in seeded:
assert entry["created_by_user"]["name"] == str(entry["created_by_user"]["id"]).replace(
f"chunk-{marker}-", "ChunkUser-"
), entry
finally:
write_rows('DELETE FROM "LiteLLM_AccessGroupTable" WHERE access_group_name LIKE %s', (f"chunkgrp-{marker}-%",))
write_rows('DELETE FROM "LiteLLM_UserTable" WHERE user_id LIKE %s', (f"chunk-{marker}-%",))
def test_access_group_endpoints_reject_a_non_admin_caller(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
user_id: Final = scenario.user(user_role="internal_user")
key: Final = scenario.key(user_id=user_id)
listing: Final = gateway.request("GET", "/v1/access_group", key=key)
assert listing.status_code == 401, listing.text
def test_access_group_get_unknown_id_returns_404(gateway: Gateway) -> None:
response: Final = gateway.request("GET", f"/v1/access_group/{uuid.uuid4()}")
assert response.status_code == 404, response.text
def test_access_group_put_with_malformed_body_returns_422(gateway: Gateway) -> None:
response: Final = gateway.request("PUT", f"/v1/access_group/{uuid.uuid4()}", {"access_model_names": "not-a-list"})
assert response.status_code == 422, response.text
def test_access_group_post_with_malformed_body_returns_422(gateway: Gateway) -> None:
response: Final = gateway.request("POST", "/v1/access_group", {"access_group_name": 123})
assert response.status_code == 422, response.text
def test_access_group_concurrent_updates_while_listing_stay_consistent(gateway: Gateway) -> None:
with _access_group(
gateway,
{"access_group_name": f"integration-{uuid.uuid4().hex}", "access_model_names": []},
key=gateway.key,
) as group_id:
def call(index: int) -> int:
if index % 2 == 0:
response = gateway.request("PUT", f"/v1/access_group/{group_id}", {"description": f"desc-{index}"})
else:
response = gateway.request("GET", "/v1/access_group")
return response.status_code
with ThreadPoolExecutor(max_workers=8) as pool:
statuses: Final = list(pool.map(call, range(20)))
assert all(status == 200 for status in statuses), statuses
detail: Final = gateway.request("GET", f"/v1/access_group/{group_id}")
assert detail.status_code == 200, detail.text

View file

@ -130,6 +130,9 @@ def client_and_mocks(monkeypatch):
mock_agents_table.find_many = AsyncMock(return_value=[])
mock_agents_table.update = AsyncMock(return_value=None)
mock_user_table = MagicMock()
mock_user_table.find_many = AsyncMock(return_value=[])
@asynccontextmanager
async def mock_tx():
tx = types.SimpleNamespace(
@ -146,6 +149,7 @@ def client_and_mocks(monkeypatch):
litellm_verificationtoken=mock_key_table,
litellm_mcpservertable=mock_mcp_server_table,
litellm_agentstable=mock_agents_table,
litellm_usertable=mock_user_table,
tx=mock_tx,
)
mock_prisma.db = mock_db
@ -423,6 +427,51 @@ def test_get_access_group_success(client_and_mocks, base_path, access_group_id):
assert resp.json()["access_group_id"] == access_group_id
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
def test_get_access_group_resolves_actor_display_names(client_and_mocks, base_path):
"""Get names the creator/updater from the user table: alias beats email, unresolved ids keep name=None."""
client, mock_prisma, mock_table, *_ = client_and_mocks
record = _make_access_group_record(
access_group_id="ag-123", created_by="ada", updated_by="ghost-user"
)
mock_table.find_unique = AsyncMock(return_value=record)
mock_prisma.db.litellm_usertable.find_many = AsyncMock(
return_value=[types.SimpleNamespace(user_id="ada", user_alias="Ada Reviewer", user_email="ada@x.com")]
)
resp = client.get(f"{base_path}/ag-123")
assert resp.status_code == 200
body = resp.json()
assert body["created_by_user"] == {"id": "ada", "name": "Ada Reviewer"}
assert body["updated_by_user"] == {"id": "ghost-user", "name": None}
queried_ids = mock_prisma.db.litellm_usertable.find_many.call_args.kwargs["where"]["user_id"]["in"]
assert sorted(queried_ids) == ["ada", "ghost-user"]
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
def test_list_access_groups_resolves_actor_display_names_across_records(client_and_mocks, base_path):
"""List carries created_by_user per group from one batched user query."""
client, mock_prisma, mock_table, *_ = client_and_mocks
records = [
_make_access_group_record(access_group_id="ag-1", created_by="ada", updated_by="ada"),
_make_access_group_record(access_group_id="ag-2", created_by="bob", updated_by="bob"),
]
mock_table.find_many = AsyncMock(return_value=records)
mock_prisma.db.litellm_usertable.find_many = AsyncMock(
return_value=[types.SimpleNamespace(user_id="bob", user_alias=None, user_email="bob@x.com")]
)
resp = client.get(base_path)
assert resp.status_code == 200
body = {entry["access_group_id"]: entry for entry in resp.json()}
assert body["ag-1"]["created_by_user"] == {"id": "ada", "name": None}
assert body["ag-2"]["created_by_user"] == {"id": "bob", "name": "bob@x.com"}
assert body["ag-2"]["updated_by_user"] == {"id": "bob", "name": "bob@x.com"}
mock_prisma.db.litellm_usertable.find_many.assert_awaited_once()
@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS)
def test_get_access_group_derives_assigned_teams_from_team_table(client_and_mocks, base_path):
"""Get drops ghost ids from the stored column and adds teams that carry the group but were never mirrored."""

View file

@ -9,6 +9,7 @@ from litellm.proxy.management_helpers.resource_display_names import (
agent_display_names,
key_display_names,
mcp_server_display_names,
user_display_names,
)
from litellm.types.agents import AgentResponse
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -128,3 +129,29 @@ async def test_key_empty_ids_skip_the_db():
table = _table()
assert dict(await key_display_names(_prisma(litellm_verificationtoken=table), ())) == {}
table.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_user_alias_beats_email_and_users_with_neither_are_skipped():
table = _table(
[
types.SimpleNamespace(user_id="u1", user_alias="Ada Reviewer", user_email="ada@x.com"),
types.SimpleNamespace(user_id="u2", user_alias=None, user_email="bob@x.com"),
types.SimpleNamespace(user_id="u3", user_alias=None, user_email=None),
]
)
names = await user_display_names(_prisma(litellm_usertable=table), ("u1", "u2", "u3", "missing", "u1"))
assert dict(names) == {"u1": "Ada Reviewer", "u2": "bob@x.com"}
assert sorted(table.find_many.call_args.kwargs["where"]["user_id"]["in"]) == [
"missing",
"u1",
"u2",
"u3",
]
@pytest.mark.asyncio
async def test_user_empty_ids_skip_the_db():
table = _table()
assert dict(await user_display_names(_prisma(litellm_usertable=table), ())) == {}
table.find_many.assert_not_awaited()

View file

@ -143,6 +143,20 @@ describe("AccessGroupDetail", () => {
expect(screen.getByText("A test access group")).toBeInTheDocument();
});
it("shows the creator and updater names instead of their raw user ids", () => {
renderWith({
created_by: "user-abc",
created_by_user: { id: "user-abc", name: "Ada Reviewer" },
updated_by: "user-def",
updated_by_user: { id: "user-def", name: null },
});
const creatorLink = screen.getByRole("link", { name: "Ada Reviewer" });
expect(creatorLink).toHaveAttribute("href", expect.stringContaining("user-abc"));
expect(screen.queryByText("user-abc")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "user-def" })).toHaveAttribute("href", expect.stringContaining("user-def"));
});
it("should display em dash when description is empty", () => {
renderWith({ description: null });

View file

@ -1,8 +1,8 @@
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
import { useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { UserReference } from "@/components/shared/EntityReference";
import CopyButton from "@/components/shared/CopyButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -127,27 +127,27 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
<CardTitle>Group Details</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
<dl className="grid w-1/2 grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Description</dt>
<dd className="text-foreground">{accessGroup.description || "—"}</dd>
<dt className="text-muted-foreground">Created</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(accessGroup.created_at).toLocaleString()}
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
<span className="shrink-0 whitespace-nowrap">{new Date(accessGroup.created_at).toLocaleString()}</span>
{accessGroup.created_by && (
<>
<span>by</span>
<DefaultProxyAdminTag userId={accessGroup.created_by} />
</>
<span className="flex min-w-0 grow basis-24 items-center gap-1">
<span className="shrink-0">by</span>
<UserReference userId={accessGroup.created_by} displayName={accessGroup.created_by_user?.name} />
</span>
)}
</dd>
<dt className="text-muted-foreground">Last Updated</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(accessGroup.updated_at).toLocaleString()}
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
<span className="shrink-0 whitespace-nowrap">{new Date(accessGroup.updated_at).toLocaleString()}</span>
{accessGroup.updated_by && (
<>
<span>by</span>
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
</>
<span className="flex min-w-0 grow basis-24 items-center gap-1">
<span className="shrink-0">by</span>
<UserReference userId={accessGroup.updated_by} displayName={accessGroup.updated_by_user?.name} />
</span>
)}
</dd>
</dl>

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
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 { useInfiniteUsers, useUserDisplayNames, useUserEmailLookup, useUserLookup } from "./useUsers";
import { userListCall } from "@/components/networking";
import type { UserListResponse } from "@/components/networking";
@ -404,3 +404,122 @@ describe("useUserEmailLookup", () => {
expect(userListCall).not.toHaveBeenCalled();
});
});
describe("useUserDisplayNames", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("prefers the alias, falls back to the email, and omits users with neither", async () => {
const response = buildUserListResponse(1, 1, 2);
vi.mocked(userListCall).mockResolvedValue({
...response,
users: [
{ ...response.users[0], user_alias: "Ada Reviewer" },
{ ...response.users[1], user_alias: null },
],
});
const { result } = renderHook(() => useUserDisplayNames(["user-1-0", "user-1-1"]), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
"user-1-0": "Ada Reviewer",
"user-1-1": "user-1-1@example.com",
});
});
it("omits users that have neither alias nor email so callers fall back to the id", async () => {
const response = buildUserListResponse(1, 1, 2);
vi.mocked(userListCall).mockResolvedValue({
...response,
users: [{ ...response.users[0], user_email: null, user_alias: null }, response.users[1]],
});
const { result } = renderHook(() => useUserDisplayNames(["user-1-0", "user-1-1"]), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ "user-1-1": "user-1-1@example.com" });
});
it("does not query with no ids", async () => {
const { result } = renderHook(() => useUserDisplayNames([]), { wrapper });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.fetchStatus).toBe("idle");
expect(userListCall).not.toHaveBeenCalled();
});
it("pages through ids in chunks of the max page size and merges the names", async () => {
const ids = Array.from({ length: 101 }, (_, i) => `u${String(i).padStart(3, "0")}`);
const response = buildUserListResponse(1, 1, 0);
vi.mocked(userListCall).mockImplementation(async (_token, userIDs) => ({
...response,
users: [
{
...response.users[0],
user_id: userIDs![0],
user_alias: `Alias ${userIDs![0]}`,
} as UserListResponse["users"][number],
],
}));
const { result } = renderHook(() => useUserDisplayNames(ids), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(userListCall).toHaveBeenCalledTimes(2);
expect(vi.mocked(userListCall).mock.calls[0][1]).toHaveLength(100);
expect(vi.mocked(userListCall).mock.calls[1][1]).toEqual(["u100", "u100"]);
expect(result.current.data).toEqual({ u000: "Alias u000", u100: "Alias u100" });
});
it("requests the max page size and keeps only exact id matches", async () => {
const response = buildUserListResponse(1, 1, 0);
vi.mocked(userListCall).mockResolvedValue({
...response,
users: [
{ ...response.users[0], user_id: "ada-reviewer", user_alias: "Wrong" },
{ ...response.users[0], user_id: "ada", user_alias: "Ada" },
],
});
const { result } = renderHook(() => useUserDisplayNames(["ada"]), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(userListCall).toHaveBeenCalledWith("test-access-token", ["ada", "ada"], 1, 100);
expect(result.current.data).toEqual({ ada: "Ada" });
});
it("resolves a single id even when substring matches would fill the page", async () => {
const allUsers = [
...Array.from({ length: 100 }, (_, i) => ({
user_id: `ada-${i + 1}`,
user_alias: `Ada ${i + 1}`,
user_email: null,
})),
{ user_id: "ada", user_alias: "Ada Exact", user_email: null },
];
const response = buildUserListResponse(1, 1, 0);
vi.mocked(userListCall).mockImplementation(async (_token, userIDs, _page, pageSize) => ({
...response,
users:
userIDs!.length === 1
? (allUsers
.filter((user) => user.user_id.includes(userIDs![0]))
.slice(0, pageSize) as UserListResponse["users"])
: (allUsers.filter((user) => userIDs!.includes(user.user_id)) as UserListResponse["users"]),
}));
const { result } = renderHook(() => useUserDisplayNames(["ada"]), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ ada: "Ada Exact" });
});
});

View file

@ -56,6 +56,39 @@ export const useUserEmailLookup = (userIds: readonly string[]) => {
});
};
const userDisplayNameKeys = createQueryKeys("userDisplayNames");
export const useUserDisplayNames = (userIds: readonly string[]) => {
const { accessToken, userRole } = useAuthorized();
const distinctIds = Array.from(new Set(userIds.filter((id) => id !== ""))).sort();
return useQuery<Record<string, string>>({
queryKey: userDisplayNameKeys.list({ filters: { ids: JSON.stringify(distinctIds) } }),
queryFn: async () => {
const chunks: string[][] = [];
for (let i = 0; i < distinctIds.length; i += USER_LIST_MAX_PAGE_SIZE) {
chunks.push(distinctIds.slice(i, i + USER_LIST_MAX_PAGE_SIZE));
}
const responses = await Promise.all(
chunks.map((ids) =>
// a single user_ids value is a substring search on /user/list
userListCall(accessToken!, ids.length === 1 ? [ids[0], ids[0]] : ids, 1, USER_LIST_MAX_PAGE_SIZE),
),
);
return Object.fromEntries(
responses.flatMap((response, chunkIndex) => {
const requested = new Set(chunks[chunkIndex]);
return response.users.flatMap((user) => {
if (!requested.has(user.user_id)) return [];
const name = user.user_alias || user.user_email;
return name ? [[user.user_id, name]] : [];
});
}),
);
},
enabled: Boolean(accessToken) && distinctIds.length > 0 && canListUsers(userRole),
});
};
export const useUserLookup = (userId: string | null) => {
const { accessToken, userRole } = useAuthorized();
return useQuery<UserInfo | null>({

View file

@ -7,6 +7,8 @@ import { MCPGatewaySessionsTab, describeTerminateResult, formatIdleSeconds } fro
import * as networking from "@/components/networking";
import type { MCPGatewaySessionsResponse, MCPGatewaySessionsTerminateResponse } from "@/components/mcp_tools/types";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/components/networking", () => ({
fetchMCPGatewaySessions: vi.fn(),
terminateMCPGatewaySessions: vi.fn(),

View file

@ -16,6 +16,8 @@ import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { fetchMCPGatewaySessions, terminateMCPGatewaySessions } from "@/components/networking";
import { EntityReference, UserReference } from "@/components/shared/EntityReference";
import { teamDetailHref } from "@/utils/entityLinks";
import type {
MCPGatewaySessionGroupCount,
MCPGatewaySessionSelector,
@ -216,16 +218,21 @@ function SessionsBody({
{session.user_id === null ? (
<span className="text-muted-foreground">{UNKNOWN_LABEL}</span>
) : (
<>
<span className="font-mono text-xs">{session.user_id}</span>
{session.user_email ? (
<span className="ml-1 text-xs text-muted-foreground">{session.user_email}</span>
) : null}
</>
<UserReference userId={session.user_id} displayName={session.user_email} />
)}
</TableCell>
<TableCell className="text-xs">{session.key_alias ?? "-"}</TableCell>
<TableCell className="text-xs">{session.team_alias ?? session.team_id ?? "-"}</TableCell>
<TableCell className="text-xs">
{session.team_id ? (
<EntityReference
id={session.team_id}
name={session.team_alias}
href={teamDetailHref(session.team_id)}
/>
) : (
session.team_alias ?? "-"
)}
</TableCell>
<TableCell className="font-mono text-xs">{session.client_ip || "-"}</TableCell>
<TableCell className="text-right text-xs">{formatIdleSeconds(session.idle_seconds)}</TableCell>
<TableCell className="text-right text-xs">{session.in_flight_requests}</TableCell>

View file

@ -9,6 +9,8 @@ import MCPServers, { compareServers, type SortKey } from "./mcp_servers";
import type { MCPServer } from "@/components/mcp_tools/types";
import * as networking from "@/components/networking";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
// Mock the networking module
vi.mock("@/components/networking", () => ({
fetchMCPServers: vi.fn(),

View file

@ -6,6 +6,12 @@ import { describe, expect, it, vi } from "vitest";
import { MemoryRow } from "@/components/networking";
import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const makeMemory = (overrides: Partial<MemoryRow> = {}): MemoryRow => ({
memory_id: "mem-1",
@ -66,8 +72,10 @@ describe("MemoryDetailDrawer", () => {
it("attributes the created and updated timestamps to their actors", () => {
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument();
expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "alice" })).toHaveAttribute("href", expect.stringContaining("alice"));
expect(screen.getByText(/^Created /)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
expect(screen.getByText(/^Updated /)).toBeInTheDocument();
});
it("renders an em dash for a timestamp the backend did not send", () => {

View file

@ -4,6 +4,8 @@ import React from "react";
import { MemoryRow } from "@/components/networking";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { UserReference } from "@/components/shared/EntityReference";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
interface MemoryDetailDrawerProps {
row: MemoryRow | null;
@ -25,6 +27,9 @@ function formatTimestamp(ts?: string): string {
}
export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
const { data: displayNames } = useUserDisplayNames(
React.useMemo(() => [row?.created_by ?? "", row?.updated_by ?? ""], [row]),
);
return (
<Sheet
open={!!row}
@ -69,12 +74,26 @@ export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
Created {formatTimestamp(row.created_at)}
{row.created_by ? ` by ${row.created_by}` : ""}
{row.created_by ? (
<>
{" by "}
<UserReference userId={row.created_by} displayName={displayNames?.[row.created_by]} />
</>
) : (
""
)}
</span>
<span aria-hidden="true">·</span>
<span>
Updated {formatTimestamp(row.updated_at)}
{row.updated_by ? ` by ${row.updated_by}` : ""}
{row.updated_by ? (
<>
{" by "}
<UserReference userId={row.updated_by} displayName={displayNames?.[row.updated_by]} />
</>
) : (
""
)}
</span>
</div>
</div>

View file

@ -8,6 +8,8 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
import AllModelsTab from "./AllModelsTab";
import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const mockModelDeleteCall = vi.fn().mockResolvedValue({});
const mockModelPatchUpdateCall = vi.fn().mockResolvedValue({});
vi.mock("@/components/networking", () => ({
@ -568,6 +570,10 @@ describe("AllModelsTab", () => {
});
it("opens the team detail view from the team ID cell", async () => {
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
const user = userEvent.setup();
renderWithProviders(<AllModelsTab {...defaultProps} />);

View file

@ -6,6 +6,8 @@ import { ModelData } from "@/components/model_dashboard/types";
import { AllModelsTable } from "./AllModelsTable";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const makeModel = (overrides: Partial<ModelData> = {}): ModelData =>
({
model_name: "gpt-4-public",
@ -480,6 +482,10 @@ describe("AllModelsTable", () => {
});
it("runs the full reset from the filter drawer", async () => {
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
const user = userEvent.setup();
const onResetFilters = vi.fn();
render(<AllModelsTable {...baseProps} onResetFilters={onResetFilters} />);

View file

@ -12,6 +12,7 @@ import {
DataTableToolbar,
} from "@/components/shared/DataTable";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
@ -132,6 +133,9 @@ export function AllModelsTable({
credentialLabels,
}: AllModelsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const { data: displayNames } = useUserDisplayNames(
useMemo(() => data.map((model) => model.model_info.created_by ?? ""), [data]),
);
const columns = useMemo(() => {
const columnDeps = {
@ -144,6 +148,7 @@ export function AllModelsTable({
onTogglePauseClick,
pausingModelId,
credentialLabels,
resolveUserName: (userId: string) => displayNames?.[userId],
};
return getModelsTableColumns(columnDeps);
}, [
@ -156,6 +161,7 @@ export function AllModelsTable({
onTogglePauseClick,
pausingModelId,
credentialLabels,
displayNames,
]);
const modelGroupOptions = useMemo(

View file

@ -8,6 +8,7 @@ import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { ModelData } from "@/components/model_dashboard/types";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { CellTooltip, DateCell, formatCellDate, IdCell, StatusBadge } from "@/components/shared/table_cells";
import { UserReference } from "@/components/shared/EntityReference";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
@ -181,17 +182,30 @@ function CredentialsCell({ credentialName, label }: { credentialName: string | u
);
}
function CreatedByCell({ model }: { model: ModelData }) {
function CreatedByCell({
model,
resolveUserName,
}: {
model: ModelData;
resolveUserName: (userId: string) => string | undefined;
}) {
const isConfigModel = !model.model_info?.db_model;
const createdAt = formatShortDate(model.model_info.created_at);
const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown";
const secondaryForDbModel = createdAt ?? "Unknown date";
const createdBy = model.model_info.created_by;
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="max-w-44 truncate text-sm text-foreground" title={primary}>
{primary}
</span>
{!isConfigModel && createdBy ? (
<span className="flex min-w-0 max-w-44 text-sm text-foreground">
<UserReference userId={createdBy} displayName={resolveUserName(createdBy)} />
</span>
) : (
<span className="max-w-44 truncate text-sm text-foreground" title={primary}>
{primary}
</span>
)}
<span className="truncate text-xs text-muted-foreground">{isConfigModel ? "-" : secondaryForDbModel}</span>
</div>
);
@ -368,6 +382,7 @@ export interface ModelsTableColumnDeps {
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise<void>;
pausingModelId?: string | null;
credentialLabels?: ReadonlyMap<string, string>;
resolveUserName?: (userId: string) => string | undefined;
}
export const getModelsTableColumns = ({
@ -380,6 +395,7 @@ export const getModelsTableColumns = ({
onTogglePauseClick,
pausingModelId,
credentialLabels,
resolveUserName = () => undefined,
}: ModelsTableColumnDeps): ColumnDef<ModelData>[] => [
{
id: MODEL_ID_COLUMN_ID,
@ -435,7 +451,7 @@ export const getModelsTableColumns = ({
enableSorting: true,
size: 180,
minSize: 110,
cell: ({ row }) => <CreatedByCell model={row.original} />,
cell: ({ row }) => <CreatedByCell model={row.original} resolveUserName={resolveUserName} />,
},
{
id: UPDATED_AT_COLUMN_ID,

View file

@ -3,6 +3,12 @@ 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";
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const mockUseProjectDetails = vi.fn();
vi.mock("@/app/(dashboard)/hooks/projects/useProjectDetails", () => ({

View file

@ -6,8 +6,9 @@ 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 { UserReference } from "@/components/shared/EntityReference";
import CopyButton from "@/components/shared/CopyButton";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
import { StatusBadge } from "@/components/shared/table_cells/status_badge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -28,6 +29,9 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamInfo } = useTeam(project?.team_id ?? undefined);
const { userId } = useAuthorized();
const { data: displayNames } = useUserDisplayNames(
useMemo(() => [project?.created_by ?? "", project?.updated_by ?? ""], [project]),
);
const canEditProject = useCanManageProjects(
isUserTeamAdminForSingleTeam(teamInfo?.members_with_roles ?? null, userId ?? ""),
);
@ -107,27 +111,27 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
<dl className="grid w-1/2 grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Description</dt>
<dd className="text-foreground">{project.description || "—"}</dd>
<dt className="text-muted-foreground">Created</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(project.created_at).toLocaleString()}
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
<span className="shrink-0 whitespace-nowrap">{new Date(project.created_at).toLocaleString()}</span>
{project.created_by && (
<>
<span>by</span>
<DefaultProxyAdminTag userId={project.created_by} />
</>
<span className="flex min-w-0 grow basis-24 items-center gap-1">
<span className="shrink-0">by</span>
<UserReference userId={project.created_by} displayName={displayNames?.[project.created_by]} />
</span>
)}
</dd>
<dt className="text-muted-foreground">Last Updated</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(project.updated_at).toLocaleString()}
<dd className="flex min-w-0 flex-wrap items-center gap-x-1 text-foreground">
<span className="shrink-0 whitespace-nowrap">{new Date(project.updated_at).toLocaleString()}</span>
{project.updated_by && (
<>
<span>by</span>
<DefaultProxyAdminTag userId={project.updated_by} />
</>
<span className="flex min-w-0 grow basis-24 items-center gap-1">
<span className="shrink-0">by</span>
<UserReference userId={project.updated_by} displayName={displayNames?.[project.updated_by]} />
</span>
)}
</dd>
</dl>
@ -188,7 +192,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</Card>
</div>
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2 [&>*]:min-w-0">
<ProjectKeysSection projectId={projectId} />
<Card className="h-full">
@ -209,7 +213,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
return (
<div className="flex flex-col gap-3">
<div>
<p className="text-base font-medium text-foreground">{teamInfo.team_alias || teamInfo.team_id}</p>
<p
className="truncate text-base font-medium text-foreground"
title={teamInfo.team_alias || teamInfo.team_id}
>
{teamInfo.team_alias || teamInfo.team_id}
</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>ID: {teamInfo.team_id}</span>
<CopyButton value={teamInfo.team_id} label="Copy team ID" />

View file

@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PromptSpec } from "@/components/networking";
import PromptTable from "./PromptTable";
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
vi.mock("@/components/networking", () => ({
modelHubCall: vi.fn().mockResolvedValue({ data: [] }),
@ -60,7 +64,7 @@ describe("PromptTable", () => {
render(<PromptTable {...defaultProps} promptsList={prompts} />);
expect(screen.getByRole("link", { name: "user-1" })).toHaveAttribute("href", "/ui/users?user=user-1");
expect(screen.getByText("default_user_id")).toBeInTheDocument();
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument();
});

View file

@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable";
import { modelHubCall, PromptSpec } from "@/components/networking";
import { getPromptTableColumns } from "./PromptTableColumns";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
import { ModelGroupInfo } from "./prompt_utils";
interface PromptTableProps {
@ -65,9 +66,19 @@ const PromptTable: React.FC<PromptTableProps> = ({
fetchModelHubData();
}, [accessToken]);
const { data: displayNames } = useUserDisplayNames(
useMemo(() => promptsList.map((prompt) => prompt.created_by ?? ""), [promptsList]),
);
const columns = useMemo(
() => getPromptTableColumns({ modelHubData, isAdmin, onPromptClick, onDeleteClick }),
[modelHubData, isAdmin, onPromptClick, onDeleteClick],
() =>
getPromptTableColumns({
modelHubData,
isAdmin,
onPromptClick,
onDeleteClick,
resolveUserName: (userId) => displayNames?.[userId],
}),
[modelHubData, isAdmin, onPromptClick, onDeleteClick, displayNames],
);
return (

View file

@ -17,7 +17,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { userDetailHref } from "@/utils/entityLinks";
import { UserReference } from "@/components/shared/EntityReference";
import { extractModel, getProviderFromModelHub, ModelGroupInfo } from "./prompt_utils";
@ -111,6 +111,7 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr
}
interface PromptTableColumnsDeps {
resolveUserName?: (userId: string) => string | undefined;
modelHubData: Map<string, ModelGroupInfo>;
isAdmin: boolean;
onPromptClick?: (id: string, environment: string) => void;
@ -118,6 +119,7 @@ interface PromptTableColumnsDeps {
}
export const getPromptTableColumns = ({
resolveUserName = () => undefined,
modelHubData,
isAdmin,
onPromptClick,
@ -196,12 +198,8 @@ export const getPromptTableColumns = ({
return <span className="text-muted-foreground">-</span>;
}
return (
<span className="block max-w-60" title={createdBy}>
<IdentityCell
title={createdBy}
titleClassName="font-normal text-muted-foreground"
href={userDetailHref(createdBy)}
/>
<span className="block max-w-60">
<UserReference userId={createdBy} displayName={resolveUserName(createdBy)} />
</span>
);
},

View file

@ -4,6 +4,12 @@ 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";
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/components/networking", () => ({
getPromptInfo: vi.fn(),

View file

@ -17,6 +17,8 @@ import { toast } from "@/lib/toast";
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";
import { UserReference } from "@/components/shared/EntityReference";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
export interface PromptInfoProps {
promptId: string;
@ -51,6 +53,12 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
const [versionHistory, setVersionHistory] = useState<PromptSpec[]>([]);
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
const [loadingVersions, setLoadingVersions] = useState(false);
const { data: displayNames } = useUserDisplayNames(
React.useMemo(
() => [promptData?.created_by ?? "", ...versionHistory.map((v) => v.created_by ?? "")],
[promptData, versionHistory],
),
);
// Fetches the requested environment (or the serve-time default when omitted) plus the environments list
const fetchPromptInfo = async (environment?: string) => {
@ -329,7 +337,16 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
<Card className="block p-6">
<p>Created By</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}
displayName={displayNames?.[promptData.created_by]}
/>
) : (
"-"
)}
</h3>
</div>
</Card>
@ -379,7 +396,13 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
)}
</TableCell>
<TableCell>
<span className="text-sm">{v.created_by || "-"}</span>
<span className="text-sm">
{v.created_by ? (
<UserReference userId={v.created_by} displayName={displayNames?.[v.created_by]} />
) : (
"-"
)}
</span>
</TableCell>
<TableCell>
<span className="text-sm">{formatDate(v.created_at)}</span>

View file

@ -5,6 +5,7 @@ import {
screen,
testQueryClient,
waitFor,
within,
} from "../../../../../../tests/test-utils";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
@ -215,9 +216,10 @@ describe("UserInfoView add-to-team form", () => {
await user.click(teamField());
expect(await screen.findByTitle("Gamma Team")).toBeInTheDocument();
expect(screen.queryByTitle("Alpha Team")).not.toBeInTheDocument();
expect(screen.queryByTitle("Beta Team")).not.toBeInTheDocument();
const listbox = await screen.findByRole("listbox");
expect(within(listbox).getByTitle("Gamma Team")).toBeInTheDocument();
expect(within(listbox).queryByTitle("Alpha Team")).not.toBeInTheDocument();
expect(within(listbox).queryByTitle("Beta Team")).not.toBeInTheDocument();
});
it("shows the default role name on the trigger rather than a blank or raw value", async () => {

View file

@ -540,8 +540,10 @@ export default function UserInfoView({
{teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team) => (
<TableRow key={team.team_id}>
<TableCell>
<BadgeLink href={teamDetailHref(team.team_id)}>
{team.team_alias || team.team_id}
<BadgeLink href={teamDetailHref(team.team_id)} className="max-w-[16rem]">
<span className="min-w-0 truncate" title={team.team_alias || team.team_id}>
{team.team_alias || team.team_id}
</span>
</BadgeLink>
</TableCell>
{isProxyAdmin && (

View file

@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest";
import type { VectorStoreIndex } from "./IndexesTab";
import IndexesTable from "./IndexesTable";
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
vi.mock("next/navigation", async () => ({
...(await vi.importActual("next/navigation")),

View file

@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable";
import type { VectorStoreIndex } from "./IndexesTab";
import { getIndexesTableColumns } from "./IndexesTableColumns";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
interface IndexesTableProps {
data: VectorStoreIndex[];
@ -38,9 +39,17 @@ const IndexesTable: React.FC<IndexesTableProps> = ({
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const { data: displayNames } = useUserDisplayNames(
useMemo(() => data.map((index) => index.created_by ?? ""), [data]),
);
const columns = useMemo(
() => getIndexesTableColumns({ resolveVectorStoreId, onViewVectorStore }),
[resolveVectorStoreId, onViewVectorStore],
() =>
getIndexesTableColumns({
resolveVectorStoreId,
onViewVectorStore,
resolveUserName: (userId) => displayNames?.[userId],
}),
[resolveVectorStoreId, onViewVectorStore, displayNames],
);
return (

View file

@ -4,17 +4,19 @@ import { ColumnDef } from "@tanstack/react-table";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
import { userDetailHref } from "@/utils/entityLinks";
import { UserReference } from "@/components/shared/EntityReference";
import type { VectorStoreIndex } from "./IndexesTab";
interface IndexesTableColumnsDeps {
resolveVectorStoreId: (name: string) => string | undefined;
resolveUserName?: (userId: string) => string | undefined;
onViewVectorStore: (vectorStoreId: string) => void;
}
export const getIndexesTableColumns = ({
resolveVectorStoreId,
resolveUserName = () => undefined,
onViewVectorStore,
}: IndexesTableColumnsDeps): ColumnDef<VectorStoreIndex>[] => [
{
@ -84,12 +86,9 @@ export const getIndexesTableColumns = ({
const createdBy = row.original.created_by;
if (createdBy) {
return (
<IdentityCell
title={createdBy}
titleClassName="font-normal"
className="max-w-48"
href={userDetailHref(createdBy)}
/>
<span className="block max-w-48">
<UserReference userId={createdBy} displayName={resolveUserName(createdBy)} />
</span>
);
}
return <span className="block max-w-48 truncate text-sm">-</span>;

View file

@ -6,6 +6,16 @@ import { credentialListCall, indexesListCall, vectorStoreListCall } from "@/comp
import VectorStoreManagement from "./index";
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
vi.mock("next/navigation", async () => ({
...(await vi.importActual("next/navigation")),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/components/networking", () => ({
vectorStoreListCall: vi.fn(),
vectorStoreDeleteCall: vi.fn(),

View file

@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest";
import { DataTable } from "@/components/shared/DataTable";
import { getMCPHubTableColumns, MCPServerData } from "./MCPHubTableColumns";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const SERVER_URL = "https://mcp.exa.ai/mcp";
const mockServer: MCPServerData = {

View file

@ -6,6 +6,7 @@ import { Copy, Info, MoreHorizontal } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { MCP_REACHABLE_DESCRIPTION } from "@/components/mcp_tools/types";
import { IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
import { UserReference } from "@/components/shared/EntityReference";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
@ -88,9 +89,13 @@ function MCPHubRowActions({ server, onServerClick }: MCPHubRowActionsProps) {
interface MCPHubTableColumnsDeps {
onServerClick: (server: MCPServerData) => void;
resolveUserName?: (userId: string) => string | undefined;
}
export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps): ColumnDef<MCPServerData>[] => [
export const getMCPHubTableColumns = ({
onServerClick,
resolveUserName = () => undefined,
}: MCPHubTableColumnsDeps): ColumnDef<MCPServerData>[] => [
{
id: "server_name",
accessorKey: "server_name",
@ -194,11 +199,14 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
size: 140,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<span className="block max-w-60 truncate text-xs" title={row.original.created_by || undefined}>
{row.original.created_by || "-"}
</span>
),
cell: ({ row }) =>
row.original.created_by ? (
<span className="flex min-w-0 max-w-60 text-xs">
<UserReference userId={row.original.created_by} displayName={resolveUserName(row.original.created_by)} />
</span>
) : (
<span className="block max-w-60 truncate text-xs">-</span>
),
},
{
id: "is_public",

View file

@ -3,6 +3,8 @@ import MakeAgentPublicForm from "@/components/AIHub/forms/MakeAgentPublicForm";
import MakeMCPPublicForm from "@/components/AIHub/forms/MakeMCPPublicForm";
import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm";
import { getMCPHubTableColumns, MCPServerData } from "@/components/AIHub/MCPHubTableColumns";
import { UserReference } from "@/components/shared/EntityReference";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
import { getModelHubTableColumns, ModelHubData } from "@/components/AIHub/ModelHubTableColumns";
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement";
@ -402,7 +404,18 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
() => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]),
[agentHubData, agentSearchTerm],
);
const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]);
const { data: displayNames } = useUserDisplayNames(
useMemo(
() => (mcpHubData ?? []).flatMap((server) => [server.created_by ?? "", server.updated_by ?? ""]),
[mcpHubData],
),
);
const resolveUserName = useCallback((userId: string) => displayNames?.[userId], [displayNames]);
const mcpColumns = useMemo(
() => getMCPHubTableColumns({ onServerClick: showMcpModal, resolveUserName }),
[showMcpModal, resolveUserName],
);
// If this is a public page, use the dedicated PublicModelHub component
if (publicPage && publicPageAllowed) {
@ -1076,11 +1089,21 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">Created By:</p>
<p>{selectedMcpServer.created_by}</p>
<p>
<UserReference
userId={selectedMcpServer.created_by}
displayName={resolveUserName(selectedMcpServer.created_by)}
/>
</p>
</div>
<div>
<p className="font-medium">Updated By:</p>
<p>{selectedMcpServer.updated_by}</p>
<p>
<UserReference
userId={selectedMcpServer.updated_by}
displayName={resolveUserName(selectedMcpServer.updated_by)}
/>
</p>
</div>
<div>
<p className="font-medium">Created At:</p>

View file

@ -98,6 +98,6 @@ it("leaves the default_user_id placeholder unlinked in the deleted by cell", ()
const team = makeDeletedTeam({ deleted_by: "default_user_id", organization_id: null });
renderWithProviders(<DeletedTeamsTable teams={[team]} isLoading={false} rowCount={1} {...paginationProps} />);
expect(screen.getByText("default_user_id")).toBeInTheDocument();
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument();
});

View file

@ -8,6 +8,7 @@ import { DataTable } from "@/components/shared/DataTable";
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { getDeletedTeamsTableColumns } from "./DeletedTeamsTableColumns";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
interface DeletedTeamsTableProps {
teams: DeletedTeam[];
@ -40,7 +41,11 @@ export function DeletedTeamsTable({
}: DeletedTeamsTableProps) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => getDeletedTeamsTableColumns(), []);
const { data: displayNames } = useUserDisplayNames(
useMemo(() => teams.map((team) => team.deleted_by ?? ""), [teams]),
);
const columns = useMemo(() => getDeletedTeamsTableColumns((userId) => displayNames?.[userId]), [displayNames]);
return (
<DataTable

View file

@ -5,7 +5,8 @@ import { ColumnDef } from "@tanstack/react-table";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { orgDetailHref, userDetailHref } from "@/utils/entityLinks";
import { orgDetailHref } from "@/utils/entityLinks";
import { UserReference } from "@/components/shared/EntityReference";
function EntityCell({ value, href }: { value: string | null | undefined; href: string | undefined }) {
if (!value) {
@ -18,7 +19,9 @@ function EntityCell({ value, href }: { value: string | null | undefined; href: s
);
}
export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
export const getDeletedTeamsTableColumns = (
resolveUserName: (userId: string) => string | undefined = () => undefined,
): ColumnDef<DeletedTeam>[] => [
{
id: "team_alias",
accessorKey: "team_alias",
@ -113,7 +116,10 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
enableSorting: false,
cell: ({ row }) => {
const deletedBy = row.original.deleted_by;
return <EntityCell value={deletedBy} href={deletedBy ? userDetailHref(deletedBy) : undefined} />;
if (!deletedBy) {
return <EntityCell value={deletedBy} href={undefined} />;
}
return <UserReference userId={deletedBy} displayName={resolveUserName(deletedBy)} />;
},
},
];

View file

@ -5,6 +5,8 @@ import moment from "moment";
import { AuditLogDrawer } from "./AuditLogDrawer";
import { AuditLogEntry } from "../AuditLogsTableColumns";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("../../../common_components/DefaultProxyAdminTag", () => ({
default: ({ userId }: { userId: string }) => <span>{userId}</span>,
}));

View file

@ -2,7 +2,7 @@ import { Check, Copy } from "lucide-react";
import { useState, useCallback } from "react";
import moment from "moment";
import { AuditLogEntry, AUDIT_TABLE_NAME_DISPLAY } from "../AuditLogsTableColumns";
import DefaultProxyAdminTag from "../../../common_components/DefaultProxyAdminTag";
import { UserReference } from "@/components/shared/EntityReference";
import CopyButton from "@/components/shared/CopyButton";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
import { Button } from "@/components/ui/button";
@ -13,6 +13,7 @@ interface AuditLogDrawerProps {
open: boolean;
onClose: () => void;
log: AuditLogEntry | null;
userDisplayName?: string | null;
}
const ACTION_TONE: Record<string, StatusTone> = {
@ -68,7 +69,7 @@ function MetadataRow({ label, value }: { label: string; value: React.ReactNode }
return (
<div className="flex items-start gap-2 py-1.5">
<span className="w-36 shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="text-xs break-all text-foreground">{value}</span>
<span className="min-w-0 flex-1 text-xs break-all text-foreground">{value}</span>
</div>
);
}
@ -170,7 +171,7 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
);
}
export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
export function AuditLogDrawer({ open, onClose, log, userDisplayName }: AuditLogDrawerProps) {
if (!log) return null;
const tableDisplay = AUDIT_TABLE_NAME_DISPLAY[log.table_name] ?? log.table_name;
@ -200,7 +201,10 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
</span>
}
/>
<MetadataRow label="Changed By" value={<DefaultProxyAdminTag userId={log.changed_by} />} />
<MetadataRow
label="Changed By"
value={<UserReference userId={log.changed_by} displayName={userDisplayName} />}
/>
<MetadataRow
label="API Key (Hash)"
value={

View file

@ -18,6 +18,8 @@ vi.mock("@tanstack/react-pacer/debouncer", () => ({
import { uiAuditLogsCall } from "../../networking";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
type AuditLogsParams = NonNullable<Parameters<typeof uiAuditLogsCall>[0]["params"]>;
const PAGE_SIZE = 50;

View file

@ -1,10 +1,11 @@
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { uiAuditLogsCall } from "../../networking";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
import { AuditLogEntry } from "./AuditLogsTableColumns";
import { AuditLogsTable } from "./AuditLogsTable";
import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer";
@ -98,6 +99,10 @@ export default function AuditLogsPanel({
setDrawerOpen(true);
}, []);
const changedByIds = useMemo(() => (query.data?.audit_logs ?? []).map((log) => log.changed_by), [query.data]);
const { data: displayNames } = useUserDisplayNames(changedByIds);
const resolveUserName = useCallback((userId: string) => displayNames?.[userId], [displayNames]);
if (!premiumUser) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto text-center">
@ -129,6 +134,7 @@ export default function AuditLogsPanel({
return (
<>
<AuditLogsTable
resolveUserName={resolveUserName}
data={query.data?.audit_logs ?? []}
rowCount={query.data?.total ?? 0}
isLoading={query.isLoading}
@ -143,7 +149,12 @@ export default function AuditLogsPanel({
onViewLog={handleViewLog}
/>
<AuditLogDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} log={selectedLog} />
<AuditLogDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
log={selectedLog}
userDisplayName={selectedLog ? displayNames?.[selectedLog.changed_by] : undefined}
/>
</>
);
}

View file

@ -4,6 +4,8 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AuditLogsTable } from "./AuditLogsTable";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
import type { AuditLogEntry } from "./AuditLogsTableColumns";
const ROWS: AuditLogEntry[] = [
@ -69,6 +71,15 @@ describe("AuditLogsTable", () => {
expect(screen.getByText("sk-hash-abc")).toBeInTheDocument();
});
it("shows the resolved user name in the Changed By column when provided", () => {
renderTable({ resolveUserName: (userId) => (userId === "user-42" ? "Ada Reviewer" : undefined) });
expect(screen.getByRole("link", { name: "Ada Reviewer" })).toHaveAttribute(
"href",
expect.stringContaining("user-42"),
);
});
it("opens the detail drawer from the Object ID identity cell with the full row", async () => {
const user = userEvent.setup();
const props = renderTable();

View file

@ -28,6 +28,7 @@ interface AuditLogsTableProps {
onSearchChange?: (value: string) => void;
onRefresh: () => void;
onViewLog: (log: AuditLogEntry) => void;
resolveUserName?: (userId: string) => string | undefined;
}
const ALL_VALUE = "all";
@ -110,9 +111,10 @@ export function AuditLogsTable({
onSearchChange,
onRefresh,
onViewLog,
resolveUserName,
}: AuditLogsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]);
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog, resolveUserName }), [onViewLog, resolveUserName]);
const hasActiveSearch = Boolean(searchValue?.trim());
return (

View file

@ -3,8 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { DateCell, IdCell, IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag";
import { UserReference } from "@/components/shared/EntityReference";
export type AuditLogEntry = {
id: string;
@ -42,9 +41,13 @@ export const auditActionLabel = (action: string): string => {
interface AuditLogsTableColumnsDeps {
onViewLog: (log: AuditLogEntry) => void;
resolveUserName?: (userId: string) => string | undefined;
}
export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
export const getAuditLogsTableColumns = ({
onViewLog,
resolveUserName = () => undefined,
}: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
{
id: "updated_at",
accessorKey: "updated_at",
@ -94,7 +97,9 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
header: "Changed By",
size: 200,
enableSorting: false,
cell: ({ row }) => <DefaultProxyAdminTag userId={row.original.changed_by} />,
cell: ({ row }) => (
<UserReference userId={row.original.changed_by} displayName={resolveUserName(row.original.changed_by)} />
),
},
{
id: "changed_by_api_key",

View file

@ -63,6 +63,8 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { uiSpendLogsCall } from "../../networking";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
request_id: "req-1",
api_key: "key-1",

View file

@ -7,6 +7,8 @@ import { DataTable } from "@/components/shared/DataTable";
import type { LogEntry } from "../types";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
vi.mock("@/utils/dataUtils", async (importOriginal) => ({

View file

@ -4,7 +4,9 @@ import type { ColumnDef } from "@tanstack/react-table";
import { Copy } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { EntityReference } from "@/components/shared/EntityReference";
import { CellTooltip, DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import { teamDetailHref } from "@/utils/entityLinks";
import { copyToClipboard, getSpendString } from "@/utils/dataUtils";
import { getProviderLogoAndName } from "../../provider_info_helpers";
@ -275,7 +277,13 @@ export const getRequestLogsTableColumns = ({
header: "Team Name",
size: 150,
enableSorting: false,
cell: ({ row }) => <TruncatedText value={readMetaString(row.original.metadata, "user_api_key_team_alias")} />,
cell: ({ row }) => {
const teamId = row.original.team_id;
const alias = readMetaString(row.original.metadata, "user_api_key_team_alias");
if (!teamId && !alias) return <TruncatedText value={undefined} />;
if (!teamId) return <TruncatedText value={alias} />;
return <EntityReference id={teamId} name={alias} href={teamDetailHref(teamId)} />;
},
},
{
id: "key_hash",

View file

@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelInfoView from "./model_info_view";
import { toast } from "@/lib/toast";
import * as networking from "./networking";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock(
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
async () => await import("../../tests/mocks/complexityScorerDefaults"),
@ -1479,6 +1481,10 @@ describe("ModelInfoView", () => {
// EditAutoRouterModal only speaks complexity and semantic. Offering it for an adaptive or
// quality router lets a save write auto_router_config onto a row that stores its settings
// elsewhere. These rows stay reachable from Health Status and direct ?model= links even
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
// though the Models table now excludes auto-routers, so the button itself has to be gated.
describe("Edit Auto Router affordance", () => {
const withRouter = (litellmParams: Record<string, unknown>) => {

View file

@ -25,6 +25,8 @@ import {
import { canEditAutoRouter, canModifyModel } from "@/utils/modelPermissions";
import { teamsUserCanAssign } from "@/utils/roles";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
import { UserReference } from "@/components/shared/EntityReference";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
import ReuseCredentialsModal from "./model_add/reuse_credentials";
@ -118,6 +120,9 @@ export default function ModelInfoView({
// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
const { data: displayNames } = useUserDisplayNames(
useMemo(() => [modelData?.model_info?.created_by ?? ""], [modelData]),
);
const aliasForTeam = (teamId: string | null | undefined): string | null =>
teams?.find((team) => team.team_id === teamId)?.team_alias || null;
@ -703,7 +708,15 @@ 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}
displayName={displayNames?.[modelData.model_info.created_by]}
/>
) : (
"Not Set"
)}
</div>
</div>
@ -788,7 +801,10 @@ export default function ModelInfoView({
},
{
label: "Created By",
value: modelData?.model_info?.created_by || "Not Set",
value:
(modelData?.model_info?.created_by &&
(displayNames?.[modelData.model_info.created_by] ?? modelData.model_info.created_by)) ||
"Not Set",
},
]}
onCancel={() => setIsDeleteModalOpen(false)}

View file

@ -123,6 +123,34 @@ test("renders organization view after loading data", async () => {
expect(orgName).toBeInTheDocument();
});
test("shows the creator's email when their user alias is an empty string", async () => {
const orgWithBlankAliasCreator = {
...mockOrg,
created_by: "creator-user-id",
members: [
{
user_id: "creator-user-id",
user: { user_id: "creator-user-id", user_alias: "", user_email: "e@x.com" },
},
],
};
mockUseOrganization.mockReturnValue({ data: orgWithBlankAliasCreator, isLoading: false } as any);
renderWithProviders(
<OrganizationInfoView
organizationId="org_123"
onClose={() => {}}
accessToken="test-token"
is_org_admin={false}
is_proxy_admin={false}
userModels={[]}
/>,
);
expect(await screen.findByText("e@x.com")).toBeInTheDocument();
expect(screen.queryByText("creator-user-id")).not.toBeInTheDocument();
});
test("should display empty state when organization has no members", async () => {
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
@ -375,6 +403,10 @@ describe("organization detail tab in the URL (?org_tab=)", () => {
});
test("falls back to Overview for an unknown ?org_tab= and removes it from the URL", async () => {
vi.mock("@/app/(dashboard)/hooks/users/useUsers", async () => ({
...(await vi.importActual("@/app/(dashboard)/hooks/users/useUsers")),
useUserDisplayNames: () => ({ data: undefined }),
}));
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
render(renderOrgView(), {
wrapper: ({ children }) => (

View file

@ -5,6 +5,8 @@ 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/EntityReference";
import { useUserDisplayNames } from "@/app/(dashboard)/hooks/users/useUsers";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -57,6 +59,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const { onTabChange, hasVisited } = useVisitedTabs(tab);
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
const { data: displayNames } = useUserDisplayNames(useMemo(() => [orgData?.created_by ?? ""], [orgData?.created_by]));
const handleTabChange = (value: OrganizationTab) => {
setTab(value);
@ -128,6 +131,11 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
}
const orgMemberById = new Map((orgData.members || []).map((m) => [m.user_id, m]));
const createdByMember = orgData.created_by ? orgMemberById.get(orgData.created_by) : undefined;
const createdByName =
createdByMember?.user?.user_alias ||
createdByMember?.user?.user_email ||
(orgData.created_by ? displayNames?.[orgData.created_by] : undefined);
const orgMemberFor = (record: Member) => (record.user_id != null ? orgMemberById.get(record.user_id) : undefined);
const orgExtraColumns: MemberTableColumn[] = [
@ -152,12 +160,17 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
return (
<div className="h-screen w-full bg-background p-4">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="min-w-0">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="size-4" />
Back to Organizations
</Button>
<h1 className="text-xl font-semibold tracking-tight text-foreground">{orgData.organization_alias}</h1>
<h1
className="truncate text-xl font-semibold tracking-tight text-foreground"
title={orgData.organization_alias}
>
{orgData.organization_alias}
</h1>
<div className="flex items-center gap-1">
<span className="font-mono text-sm text-muted-foreground">{orgData.organization_id}</span>
<CopyButton value={orgData.organization_id} label="Copy organization ID" iconClassName="size-3" />
@ -186,7 +199,14 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<div className="mt-2 text-sm text-foreground">
<p>Created: {new Date(orgData.created_at).toLocaleDateString()}</p>
<p>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</p>
<p>Created By: {orgData.created_by}</p>
<p>
Created By:{" "}
{orgData.created_by ? (
<UserReference userId={orgData.created_by} displayName={createdByName} />
) : (
"-"
)}
</p>
</div>
</CardContent>
</Card>
@ -240,8 +260,10 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<p className="text-sm text-muted-foreground">Teams</p>
<div className="mt-2 flex flex-wrap gap-2">
{orgData.teams?.map((team, index) => (
<BadgeLink key={index} href={teamDetailHref(team.team_id)}>
{teamAliasMap[team.team_id] || team.team_id}
<BadgeLink key={index} href={teamDetailHref(team.team_id)} className="max-w-64">
<span className="min-w-0 truncate" title={teamAliasMap[team.team_id] || team.team_id}>
{teamAliasMap[team.team_id] || team.team_id}
</span>
</BadgeLink>
))}
</div>
@ -300,7 +322,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<div className="space-y-4 text-sm">
<div>
<p className="font-medium text-foreground">Organization Name</p>
<div>{orgData.organization_alias}</div>
<div className="truncate" title={orgData.organization_alias}>
{orgData.organization_alias}
</div>
</div>
<div>
<p className="font-medium text-foreground">Organization ID</p>

View file

@ -0,0 +1,69 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
import { EntityReference, UserReference } from "./EntityReference";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
describe("EntityReference", () => {
it("shows the name as the primary linked text when a name and href exist", () => {
render(<EntityReference id="team-123" name="Review Team" href="/ui/teams?team=team-123" />);
expect(screen.getByRole("link", { name: "Review Team" })).toHaveAttribute("href", "/ui/teams?team=team-123");
expect(screen.queryByText("team-123")).not.toBeInTheDocument();
});
it("shows the full name and the raw id together in the tooltip", async () => {
const user = userEvent.setup();
const longName = "Review Team " + "x".repeat(200);
render(<EntityReference id="team-123" name={longName} />);
const triggers = screen.getAllByText(longName);
await user.hover(triggers[0]);
const tips = await screen.findAllByText(longName);
expect(tips.length).toBeGreaterThan(1);
expect(await screen.findByText("team-123")).toBeInTheDocument();
});
it("caps the named trigger at the container width so long names truncate", () => {
const { container } = render(<EntityReference id="team-123" name="Review Team" />);
expect(container.querySelector("span")).toHaveClass("max-w-64");
});
it("falls back to the raw id in mono when there is no name", () => {
render(<EntityReference id="team-123" href="/ui/teams?team=team-123" />);
const link = screen.getByRole("link", { name: "team-123" });
expect(link).toHaveAttribute("href", "/ui/teams?team=team-123");
expect(link).toHaveClass("font-mono");
});
it("renders the raw id without a link when neither name nor href exist", () => {
render(<EntityReference id="team-123" />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(screen.getByText("team-123")).toBeInTheDocument();
});
});
describe("UserReference", () => {
it("renders nothing for a nullish user id", () => {
const { container } = render(<UserReference userId={null} />);
expect(container).toBeEmptyDOMElement();
});
it("shows the display name linked to the user detail page", () => {
render(<UserReference userId="user-9" displayName="Ada Reviewer" />);
const link = screen.getByRole("link", { name: "Ada Reviewer" });
expect(link).toHaveAttribute("href", expect.stringContaining("user-9"));
});
it("keeps the default proxy admin badge when the sentinel id has no name", () => {
render(<UserReference userId={DEFAULT_PROXY_ADMIN_USER_ID} />);
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
});
it("falls back to the raw user id when no display name resolved", () => {
render(<UserReference userId="user-9" />);
expect(screen.getByRole("link", { name: "user-9" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,49 @@
"use client";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { EntityLink } from "@/components/shared/EntityLink";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { userDetailHref } from "@/utils/entityLinks";
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
interface EntityReferenceProps {
id: string;
name?: string | null;
href?: string;
}
export function EntityReference({ id, name, href }: EntityReferenceProps) {
if (!name) {
return (
<EntityLink href={href} className="max-w-64 font-mono text-xs font-normal">
{id}
</EntityLink>
);
}
return (
<SimpleTooltip
content={
<span className="flex flex-col gap-0.5">
<span className="break-all font-medium">{name}</span>
<span className="break-all font-mono text-xs">{id}</span>
</span>
}
className="min-w-0 max-w-64"
>
<EntityLink href={href}>{name}</EntityLink>
</SimpleTooltip>
);
}
interface UserReferenceProps {
userId: string | null | undefined;
displayName?: string | null;
}
export function UserReference({ userId, displayName }: UserReferenceProps) {
if (userId == null || userId === "") return null;
if (userId === DEFAULT_PROXY_ADMIN_USER_ID && !displayName) {
return <DefaultProxyAdminTag userId={userId} />;
}
return <EntityReference id={userId} name={displayName} href={userDetailHref(userId)} />;
}

View file

@ -26,8 +26,9 @@ import { isProxyAdminRole } from "@/utils/roles";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { EntityReference } from "@/components/shared/EntityReference";
import { Badge } from "@/components/ui/badge";
import { modelGroupHref, modelOrAccessGroupHref } from "@/utils/entityLinks";
import { modelGroupHref, modelOrAccessGroupHref, orgDetailHref } from "@/utils/entityLinks";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input as UIInput } from "@/components/ui/input";
@ -2258,7 +2259,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<div className="space-y-4">
<div>
<p className="font-medium">Team Name</p>
<div>{info.team_alias}</div>
<div className="truncate" title={info.team_alias}>
{info.team_alias}
</div>
</div>
<div>
<p className="font-medium">Team ID</p>
@ -2427,8 +2430,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
)}
</div>
<div>
<p className="font-medium">Organization ID</p>
<div>{info.organization_id}</div>
<p className="font-medium">Organization</p>
<div>
{info.organization_id ? (
<EntityReference
id={info.organization_id}
name={organization?.organization_alias}
href={orgDetailHref(info.organization_id)}
/>
) : (
"-"
)}
</div>
</div>
<div>
<p className="font-medium">Status</p>
@ -2484,12 +2497,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
return (
<div className="p-4">
<div className="flex justify-between items-center mb-6">
<div>
<div className="min-w-0">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeftIcon className="h-4 w-4" />
Back to Teams
</Button>
<h1 className="text-2xl font-semibold">{info.team_alias}</h1>
<h1 className="truncate text-2xl font-semibold" title={info.team_alias}>
{info.team_alias}
</h1>
<div className="flex items-center">
<p className="text-sm text-muted-foreground font-mono">{info.team_id}</p>
<Button

View file

@ -14,8 +14,8 @@ import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EntityLink } from "@/components/shared/EntityLink";
import { modelOrAccessGroupHref, teamDetailHref } from "@/utils/entityLinks";
import { EntityReference } from "@/components/shared/EntityReference";
import { modelOrAccessGroupHref, orgDetailHref, teamDetailHref } from "@/utils/entityLinks";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { KeyInfoHeader } from "./KeyInfoHeader";
import KeySavingsTab from "./KeySavingsTab";
@ -547,6 +547,7 @@ export default function KeyInfoView({
const parentTeam = currentKeyData.team_id ? teamsData?.find((team) => team.team_id === currentKeyData.team_id) : null;
const orgId = currentKeyData.organization_id || currentKeyData.org_id || parentTeam?.organization_id || "";
const parentOrg = orgId ? organizations?.find((org) => org.organization_id === orgId) : null;
const keyOrgId = currentKeyData.organization_id ?? currentKeyData.org_id;
const hasOwnBudget = currentKeyData.max_budget !== null;
const ownerUser = keyOwnerBudgetSource(currentKeyData, applyUserBudgetToTeamKeys);
@ -943,9 +944,11 @@ export default function KeyInfoView({
<p className="text-sm font-medium">Team ID</p>
<p className="text-sm">
{currentKeyData.team_id ? (
<EntityLink href={teamDetailHref(currentKeyData.team_id)} className="font-normal">
{currentKeyData.team_id}
</EntityLink>
<EntityReference
id={currentKeyData.team_id}
name={parentTeam?.team_alias}
href={teamDetailHref(currentKeyData.team_id)}
/>
) : (
"Not Set"
)}
@ -970,7 +973,17 @@ export default function KeyInfoView({
<div>
<p className="text-sm font-medium">Organization</p>
<p className="text-sm">{(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}</p>
<p className="text-sm">
{keyOrgId ? (
<EntityReference
id={keyOrgId}
name={parentOrg?.organization_id === keyOrgId ? parentOrg.organization_alias : undefined}
href={orgDetailHref(keyOrgId)}
/>
) : (
"Not Set"
)}
</p>
</div>
<div>

View file

@ -24994,6 +24994,7 @@ export interface components {
created_at: string;
/** Created By */
created_by?: string | null;
created_by_user?: components["schemas"]["AccessGroupResource"] | null;
/** Description */
description?: string | null;
/**
@ -25003,6 +25004,7 @@ export interface components {
updated_at: string;
/** Updated By */
updated_by?: string | null;
updated_by_user?: components["schemas"]["AccessGroupResource"] | null;
};
/** AccessGroupUpdateRequest */
AccessGroupUpdateRequest: {