From 9d40cd4df7e7a518ee2df6029fad94eaed3c91fc Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 17 Aug 2026 10:17:28 -0700 Subject: [PATCH] feat(ui): link key info header to its user, creator, team, and organization (#37187) * feat(ui): link key info header to its user, creator, team, and organization The key info page showed the owning user and creator as plain text and never surfaced the team or organization at all, so walking from a key to its parent entities meant copying ids into other pages. The header now renders User and Created By as links to the user detail page, and gains a far-right column with Team and Organization links (alias when known, id otherwise). Client-side navigation logic shared by BadgeLink and IdentityCell moves into a reusable EntityLink so all entity links behave the same * test(ui): mock next/navigation in VirtualKeysTable test KeyInfoView now renders EntityLink, which calls useRouter, so the table test that opens the key detail needs the app router mocked --- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 2 + .../common_components/LabeledField.test.tsx | 34 ++++++- .../common_components/LabeledField.tsx | 16 +++- .../src/components/shared/BadgeLink.tsx | 20 ++--- .../src/components/shared/EntityLink.test.tsx | 36 ++++++++ .../src/components/shared/EntityLink.tsx | 43 +++++++++ .../shared/table_cells/identity_cell.tsx | 12 +-- .../templates/KeyInfoHeader.test.tsx | 75 ++++++++++++++++ .../components/templates/KeyInfoHeader.tsx | 35 +++++++- .../KeyInfoView.handleKeyUpdate.test.tsx | 5 ++ .../key_info_view.budget_display.test.tsx | 7 ++ .../templates/key_info_view.test.tsx | 90 +++++++++++++++++++ .../components/templates/key_info_view.tsx | 21 ++++- ui/litellm-dashboard/src/utils/entityLinks.ts | 4 + 14 files changed, 374 insertions(+), 26 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/EntityLink.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/EntityLink.tsx diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 0755ddb96fc..0910925a940 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -23,6 +23,8 @@ vi.mock("@tanstack/react-pacer/debouncer", async () => { }; }); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn(() => ({ accessToken: "test-token", diff --git a/ui/litellm-dashboard/src/components/common_components/LabeledField.test.tsx b/ui/litellm-dashboard/src/components/common_components/LabeledField.test.tsx index a55381b344f..c211f256809 100644 --- a/ui/litellm-dashboard/src/components/common_components/LabeledField.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/LabeledField.test.tsx @@ -1,7 +1,9 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import LabeledField from "./LabeledField"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + describe("LabeledField", () => { it("should render the label and value", () => { render(); @@ -50,4 +52,34 @@ describe("LabeledField", () => { render(); expect(screen.getByRole("button", { name: "Copy User ID" })).toBeInTheDocument(); }); + + it("should render the value as a link when href is provided", () => { + render(); + expect(screen.getByRole("link", { name: "my-team" })).toHaveAttribute("href", "/ui/teams?team=t1"); + }); + + it("should keep the copy button next to a linked value", () => { + render(); + expect(screen.getByRole("link", { name: "alice" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy Created By" })).toBeInTheDocument(); + }); + + it("should not link an empty value even when href is provided", () => { + render(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("should not link the Default Proxy Admin tag", () => { + render( + , + ); + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/common_components/LabeledField.tsx b/ui/litellm-dashboard/src/components/common_components/LabeledField.tsx index 6107448babe..9f45b05f306 100644 --- a/ui/litellm-dashboard/src/components/common_components/LabeledField.tsx +++ b/ui/litellm-dashboard/src/components/common_components/LabeledField.tsx @@ -1,5 +1,6 @@ import React from "react"; import CopyButton from "@/components/shared/CopyButton"; +import { EntityLink } from "@/components/shared/EntityLink"; import { cx } from "@/lib/cva.config"; import DefaultProxyAdminTag from "./DefaultProxyAdminTag"; @@ -7,6 +8,7 @@ interface LabeledFieldProps { label: string; value: string; icon?: React.ReactNode; + href?: string; truncate?: boolean; copyable?: boolean; defaultUserIdCheck?: boolean; @@ -16,6 +18,7 @@ export default function LabeledField({ label, value, icon, + href, truncate = false, copyable = false, defaultUserIdCheck = false, @@ -24,14 +27,21 @@ export default function LabeledField({ const isDefaultUser = defaultUserIdCheck && value === "default_user_id"; const displayValue = isEmpty ? "-" : value; const isCopyable = copyable && !isEmpty && !isDefaultUser; + const isLink = href != null && !isEmpty && !isDefaultUser; const valueEl = isDefaultUser ? ( ) : ( - - {displayValue} - + {isLink ? ( + + {displayValue} + + ) : ( + + {displayValue} + + )} {isCopyable && } ); diff --git a/ui/litellm-dashboard/src/components/shared/BadgeLink.tsx b/ui/litellm-dashboard/src/components/shared/BadgeLink.tsx index 444d2acdcfe..06185249500 100644 --- a/ui/litellm-dashboard/src/components/shared/BadgeLink.tsx +++ b/ui/litellm-dashboard/src/components/shared/BadgeLink.tsx @@ -1,8 +1,8 @@ "use client"; -import { useRouter } from "next/navigation"; import * as React from "react"; +import { useEntityLinkClick } from "@/components/shared/EntityLink"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/cva.config"; @@ -16,8 +16,6 @@ interface BadgeLinkProps { } export function BadgeLink({ href, variant = "secondary", className, children }: BadgeLinkProps) { - const router = useRouter(); - if (!href) { return ( @@ -26,13 +24,15 @@ export function BadgeLink({ href, variant = "secondary", className, children }: ); } - const handleClick = (e: React.MouseEvent) => { - const hasModifierKey = e.metaKey || e.ctrlKey || e.shiftKey; - const isNativeNewTabClick = hasModifierKey || e.button === 1; - if (isNativeNewTabClick) return; - e.preventDefault(); - router.push(href); - }; + return ( + + {children} + + ); +} + +function LinkedBadge({ href, variant, className, children }: BadgeLinkProps & { href: string }) { + const handleClick = useEntityLinkClick(href); return ( ({ useRouter: () => ({ push }) })); + +describe("EntityLink", () => { + beforeEach(() => { + push.mockClear(); + }); + + it("renders an anchor pointing at the target href", () => { + render(alice); + expect(screen.getByRole("link", { name: "alice" })).toHaveAttribute("href", "/ui/users?user=u1"); + }); + + it("navigates client-side on plain click", async () => { + const user = userEvent.setup(); + render(alice); + await user.click(screen.getByRole("link", { name: "alice" })); + expect(push).toHaveBeenCalledWith("/ui/users?user=u1"); + }); + + it("leaves modified clicks to the browser so new-tab shortcuts keep working", async () => { + const user = userEvent.setup(); + render(alice); + await user.keyboard("{Meta>}"); + await user.click(screen.getByRole("link", { name: "alice" })); + await user.keyboard("{/Meta}"); + expect(push).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/EntityLink.tsx b/ui/litellm-dashboard/src/components/shared/EntityLink.tsx new file mode 100644 index 00000000000..4054b929943 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/EntityLink.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { useRouter } from "next/navigation"; +import * as React from "react"; + +import { cn } from "@/lib/cva.config"; + +export function useEntityLinkClick(href: string): (e: React.MouseEvent) => void { + const router = useRouter(); + + return (e: React.MouseEvent) => { + const hasModifierKey = e.metaKey || e.ctrlKey || e.shiftKey; + const isNativeNewTabClick = hasModifierKey || e.button === 1; + if (isNativeNewTabClick) return; + e.preventDefault(); + router.push(href); + }; +} + +interface EntityLinkProps { + href: string; + className?: string; + children: React.ReactNode; +} + +export function EntityLink({ href, className, children }: EntityLinkProps) { + const handleClick = useEntityLinkClick(href); + + return ( + + {children} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.tsx index 62ae04bbf88..2ee49b18bc0 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.tsx @@ -1,9 +1,9 @@ "use client"; import { ChevronRight } from "lucide-react"; -import { useRouter } from "next/navigation"; import * as React from "react"; +import { useEntityLinkClick } from "@/components/shared/EntityLink"; import { cn } from "@/lib/cva.config"; interface IdentityCellProps { @@ -57,15 +57,7 @@ export function IdentityCell({ title, subtitle, badge, onClick, href, className, } function IdentityCellLink({ href, className, body }: { href: string; className?: string; body: React.ReactNode }) { - const router = useRouter(); - - const handleClick = (e: React.MouseEvent) => { - const hasModifierKey = e.metaKey || e.ctrlKey || e.shiftKey; - const isNativeNewTabClick = hasModifierKey || e.button === 1; - if (isNativeNewTabClick) return; - e.preventDefault(); - router.push(href); - }; + const handleClick = useEntityLinkClick(href); return ( diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.test.tsx index f67f70e7df9..b81495e68e8 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.test.tsx @@ -3,13 +3,20 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { KeyInfoHeader, KeyInfoData } from "./KeyInfoHeader"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + const MOCK_DATA: KeyInfoData = { keyName: "My Test Key", keyId: "sk-1234567890abcdef", userId: "user-abc-123", userEmail: "test@example.com", userAlias: null, + teamId: "team-xyz-789", + teamAlias: "Platform Team", + orgId: "org-abc-001", + orgAlias: "Acme Org", createdBy: "admin@example.com", + createdById: "admin-user-456", createdAt: "Oct 29, 2025 at 1:26 AM", lastUpdated: "Oct 29, 2025 at 1:47 AM", lastActive: "Oct 29, 2025 at 2:00 AM", @@ -37,6 +44,74 @@ describe("KeyInfoHeader", () => { expect(screen.getByText("Expires")).toBeInTheDocument(); expect(screen.getByText("Last Updated")).toBeInTheDocument(); expect(screen.getByText("Last Active")).toBeInTheDocument(); + expect(screen.getByText("Team")).toBeInTheDocument(); + expect(screen.getByText("Organization")).toBeInTheDocument(); + }); + + describe("entity links", () => { + it("links the user to the users page", () => { + render(); + expect(screen.getByRole("link", { name: "test@example.com" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=user-abc-123"), + ); + }); + + it("links the creator to the users page by user id, not by the displayed alias", () => { + render(); + expect(screen.getByRole("link", { name: "admin@example.com" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=admin-user-456"), + ); + }); + + it("shows the team alias and links it to the team page by id", () => { + render(); + expect(screen.getByRole("link", { name: "Platform Team" })).toHaveAttribute( + "href", + expect.stringContaining("/teams?team=team-xyz-789"), + ); + }); + + it("falls back to the team id when no alias is known", () => { + render(); + expect(screen.getByRole("link", { name: "team-xyz-789" })).toHaveAttribute( + "href", + expect.stringContaining("/teams?team=team-xyz-789"), + ); + }); + + it("shows the organization alias and links it to the organization page by id", () => { + render(); + expect(screen.getByRole("link", { name: "Acme Org" })).toHaveAttribute( + "href", + expect.stringContaining("/organizations?org=org-abc-001"), + ); + }); + + it("renders '-' without a link when the key has no organization", () => { + render(); + expect(screen.queryByRole("link", { name: /org/i })).not.toBeInTheDocument(); + expect(screen.getByText("Organization").parentElement?.parentElement).toHaveTextContent("-"); + }); + + it("renders '-' without a link when the key has no team", () => { + render(); + expect(screen.queryByRole("link", { name: /team/i })).not.toBeInTheDocument(); + expect(screen.getByText("Team").parentElement?.parentElement).toHaveTextContent("-"); + }); + + it("does not link the user when the key has no user id", () => { + render(); + expect(screen.getByText("orphan@example.com")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "orphan@example.com" })).not.toBeInTheDocument(); + }); + + it("keeps the Default Proxy Admin creator unlinked", () => { + render(); + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /default/i })).not.toBeInTheDocument(); + }); }); describe("back button", () => { diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx index f31a265da87..cffa356d693 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, ArrowLeftRight, Ban, + Building2, Calendar, CircleCheck, Clock, @@ -13,6 +14,7 @@ import { Timer, Trash2, User, + Users, Zap, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -27,6 +29,8 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/h import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import CopyButton from "@/components/shared/CopyButton"; +import { EntityLink } from "@/components/shared/EntityLink"; +import { orgDetailHref, teamDetailHref, userDetailHref } from "@/utils/entityLinks"; import LabeledField from "../common_components/LabeledField"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; @@ -36,7 +40,12 @@ export interface KeyInfoData { userId: string; userEmail: string; userAlias?: string | null; + teamId: string; + teamAlias?: string | null; + orgId: string; + orgAlias?: string | null; createdBy: string; + createdById: string; createdAt: string; lastUpdated: string; lastActive: string; @@ -135,7 +144,11 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
{displayValue}} + render={ + + {userId ? {displayValue} : displayValue} + + } /> {popoverContent} @@ -265,6 +278,7 @@ export function KeyInfoHeader({ label="Created By" value={data.createdBy} icon={} + href={data.createdById ? userDetailHref(data.createdById) : undefined} truncate copyable defaultUserIdCheck @@ -277,6 +291,25 @@ export function KeyInfoHeader({ } /> } />
+ + + +
+ } + href={data.teamId ? teamDetailHref(data.teamId) : undefined} + truncate + /> + } + href={data.orgId ? orgDetailHref(data.orgId) : undefined} + truncate + /> +
); diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 42d1884e563..983c91d87a7 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -17,9 +17,14 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized, })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: () => ({ data: [] }), +})); + // Networking: wire the hoisted fns so we can assert calls later vi.mock("../networking", () => { return { + serverRootPath: "", keyUpdateCall: (...args: any[]) => keyUpdateCallMock(...args), keyDeleteCall: (...args: any[]) => keyDeleteCallMock(...args), }; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx index bab720f7517..1ffee10710f 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.budget_display.test.tsx @@ -11,6 +11,12 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams"; // where the overview "Spend" card formatted `max_budget` with the default 0 // decimals, truncating sub-dollar budgets (e.g. $0.10) to "$0". +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: () => ({ data: [] }), +})); + vi.mock("./key_edit_view", () => ({ KeyEditView: () =>
, })); @@ -24,6 +30,7 @@ vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({ useResetKeySpend: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), })); vi.mock("../networking", () => ({ + serverRootPath: "", keyDeleteCall: vi.fn().mockResolvedValue({}), keyUpdateCall: vi.fn().mockResolvedValue({}), getPolicyInfoWithGuardrails: vi.fn().mockResolvedValue({ resolved_guardrails: [] }), diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 1437689958c..0d41d199b22 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -13,6 +13,12 @@ const editViewMocks = vi.hoisted(() => ({ onSubmit: undefined as ((v: Record) => Promise) | undefined, })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: () => ({ data: [] }), +})); + vi.mock("./key_edit_view", () => ({ KeyEditView: ({ onSubmit }: { onSubmit: (v: Record) => Promise }) => { editViewMocks.onSubmit = onSubmit; @@ -53,6 +59,7 @@ import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers" import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; vi.mock("../networking", () => ({ + serverRootPath: "", keyDeleteCall: vi.fn().mockResolvedValue({}), keyUpdateCall: vi.fn().mockResolvedValue({}), getPolicyInfoWithGuardrails: vi.fn().mockResolvedValue({ @@ -487,6 +494,89 @@ describe("KeyInfoView", () => { }); }); + describe("entity links in the header", () => { + const mockTeam: Team = { + team_id: "linked-team-id", + team_alias: "Linked Team", + models: [], + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + organization_id: "org-1", + created_at: "2025-01-01T00:00:00Z", + keys: [], + members_with_roles: [], + spend: 0, + }; + + beforeEach(() => { + vi.mocked(useTeams).mockReturnValue({ teams: [mockTeam], setTeams: vi.fn() }); + vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); + }); + + it("links the key's team by alias, resolved from the teams list, to the team page", async () => { + const keyData = { ...MOCK_KEY_DATA, team_id: "linked-team-id" }; + renderWithProviders( + {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />, + ); + + expect(await screen.findByRole("link", { name: "Linked Team" })).toHaveAttribute( + "href", + expect.stringContaining("/teams?team=linked-team-id"), + ); + }); + + it("links the key's user and creator to their user pages by id", async () => { + const keyData = { + ...MOCK_KEY_DATA, + user_id: "owner-user-id", + user_email: "owner@example.com", + created_by: "creator-user-id", + created_by_user: { user_id: "creator-user-id", user_email: "creator@example.com", user_alias: null }, + }; + renderWithProviders( + {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />, + ); + + expect(await screen.findByRole("link", { name: "owner@example.com" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=owner-user-id"), + ); + expect(screen.getByRole("link", { name: "creator@example.com" })).toHaveAttribute( + "href", + expect.stringContaining("/users?user=creator-user-id"), + ); + }); + + it("links the key's organization by id, falling back to the team's organization", async () => { + const keyData = { ...MOCK_KEY_DATA, team_id: "linked-team-id", organization_id: null }; + renderWithProviders( + {}} keyId="test-key-id" onKeyDataUpdate={() => {}} teams={[]} />, + ); + + expect(await screen.findByRole("link", { name: "org-1" })).toHaveAttribute( + "href", + expect.stringContaining("/organizations?org=org-1"), + ); + }); + + it("renders no team link when the key has no team", async () => { + renderWithProviders( + {}} + keyId="test-key-id" + onKeyDataUpdate={() => {}} + teams={[]} + />, + ); + + await screen.findByText("Team"); + expect(screen.queryByRole("link", { name: /team/i })).not.toBeInTheDocument(); + }); + }); + it("should call onClose when back button is clicked", async () => { vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); const onCloseMock = vi.fn(); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index a2d926dff8a..3ee11325a65 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -2,6 +2,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { ArrowLeft } from "lucide-react"; @@ -10,6 +11,8 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { EntityLink } from "@/components/shared/EntityLink"; +import { teamDetailHref } from "@/utils/entityLinks"; import { KeyInfoHeader } from "./KeyInfoHeader"; import { useEffect, useState } from "react"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles"; @@ -78,6 +81,7 @@ export default function KeyInfoView({ const queryClient = useQueryClient(); const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); const { teams: teamsData } = useTeams(); + const { data: organizations } = useOrganizations(); const { data: projects } = useProjects(); const { data: uiSettingsData } = useUISettings(); const { data: allMcpServers } = useMCPServers(); @@ -453,6 +457,8 @@ export default function KeyInfoView({ const lastConfiguredAt = currentKeyData.settings_updated_at || currentKeyData.created_at; 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 budgetDisplay = currentKeyData.max_budget !== null @@ -470,11 +476,16 @@ export default function KeyInfoView({ userId: currentKeyData.user_id || "", userEmail: currentKeyData.user_email || "", userAlias: currentKeyData.user?.user_alias ?? null, + teamId: currentKeyData.team_id || "", + teamAlias: parentTeam?.team_alias ?? null, + orgId, + orgAlias: parentOrg?.organization_alias ?? null, createdBy: currentKeyData.created_by_user?.user_alias || currentKeyData.created_by_user?.user_email || currentKeyData.created_by || "", + createdById: currentKeyData.created_by_user?.user_id || currentKeyData.created_by || "", createdAt: currentKeyData.created_at ? formatTimestamp(currentKeyData.created_at) : "", lastUpdated: lastConfiguredAt ? formatTimestamp(lastConfiguredAt) : "", lastActive: currentKeyData.last_active ? formatTimestamp(currentKeyData.last_active) : "Never", @@ -766,7 +777,15 @@ export default function KeyInfoView({

Team ID

-

{currentKeyData.team_id || "Not Set"}

+

+ {currentKeyData.team_id ? ( + + {currentKeyData.team_id} + + ) : ( + "Not Set" + )} +

{enableProjectsUI && ( diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index b0829d15d1d..675ac8d0554 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -11,3 +11,7 @@ export function keyDetailHref(keyToken: string): string { export function userDetailHref(userId: string): string { return `${migratedHref("users")}?user=${encodeURIComponent(userId)}`; } + +export function orgDetailHref(orgId: string): string { + return `${migratedHref("organizations")}?org=${encodeURIComponent(orgId)}`; +}