mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/patch-endpoint-key-update-65648c
This commit is contained in:
commit
8512bbc2c5
14 changed files with 374 additions and 26 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(<LabeledField label="User Email" value="test@example.com" />);
|
||||
|
|
@ -50,4 +52,34 @@ describe("LabeledField", () => {
|
|||
render(<LabeledField label="User ID" value="user-123" copyable />);
|
||||
expect(screen.getByRole("button", { name: "Copy User ID" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the value as a link when href is provided", () => {
|
||||
render(<LabeledField label="Team" value="my-team" href="/ui/teams?team=t1" />);
|
||||
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(<LabeledField label="Created By" value="alice" href="/ui/users?user=u1" copyable />);
|
||||
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(<LabeledField label="Team" value="" href="/ui/teams?team=t1" />);
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not link the Default Proxy Admin tag", () => {
|
||||
render(
|
||||
<LabeledField
|
||||
label="Created By"
|
||||
value="default_user_id"
|
||||
href="/ui/users?user=default_user_id"
|
||||
defaultUserIdCheck
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<DefaultProxyAdminTag userId={value} />
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<strong className={cx("font-semibold", truncate ? "block max-w-40 truncate" : "break-words")}>
|
||||
{displayValue}
|
||||
</strong>
|
||||
{isLink ? (
|
||||
<EntityLink href={href} className={cx(truncate && "max-w-40")}>
|
||||
{displayValue}
|
||||
</EntityLink>
|
||||
) : (
|
||||
<strong className={cx("font-semibold", truncate ? "block max-w-40 truncate" : "break-words")}>
|
||||
{displayValue}
|
||||
</strong>
|
||||
)}
|
||||
{isCopyable && <CopyButton value={value} label={`Copy ${label}`} />}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Badge variant={variant} className={cn(ENTITY_BADGE_SIZE, className)}>
|
||||
|
|
@ -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 (
|
||||
<LinkedBadge href={href} variant={variant} className={className}>
|
||||
{children}
|
||||
</LinkedBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedBadge({ href, variant, className, children }: BadgeLinkProps & { href: string }) {
|
||||
const handleClick = useEntityLinkClick(href);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { EntityLink } from "./EntityLink";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||
|
||||
describe("EntityLink", () => {
|
||||
beforeEach(() => {
|
||||
push.mockClear();
|
||||
});
|
||||
|
||||
it("renders an anchor pointing at the target href", () => {
|
||||
render(<EntityLink href="/ui/users?user=u1">alice</EntityLink>);
|
||||
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(<EntityLink href="/ui/users?user=u1">alice</EntityLink>);
|
||||
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(<EntityLink href="/ui/users?user=u1">alice</EntityLink>);
|
||||
await user.keyboard("{Meta>}");
|
||||
await user.click(screen.getByRole("link", { name: "alice" }));
|
||||
await user.keyboard("{/Meta}");
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
43
ui/litellm-dashboard/src/components/shared/EntityLink.tsx
Normal file
43
ui/litellm-dashboard/src/components/shared/EntityLink.tsx
Normal file
|
|
@ -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 (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"group inline-flex min-w-0 max-w-full items-center gap-0.5 font-semibold underline-offset-4 hover:underline",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<a href={href} onClick={handleClick} className={cn(INTERACTIVE_CELL_CLASSES, className)}>
|
||||
|
|
|
|||
|
|
@ -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(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
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(<KeyInfoHeader data={{ ...MOCK_DATA, teamAlias: null }} />);
|
||||
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(<KeyInfoHeader data={MOCK_DATA} />);
|
||||
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(<KeyInfoHeader data={{ ...MOCK_DATA, orgId: "", orgAlias: null }} />);
|
||||
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(<KeyInfoHeader data={{ ...MOCK_DATA, teamId: "", teamAlias: null }} />);
|
||||
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(<KeyInfoHeader data={{ ...MOCK_DATA, userId: "", userEmail: "orphan@example.com" }} />);
|
||||
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(<KeyInfoHeader data={{ ...MOCK_DATA, createdBy: "default_user_id", createdById: "default_user_id" }} />);
|
||||
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /default/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("back button", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<div>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
render={<span className="block max-w-[200px] cursor-default truncate font-semibold">{displayValue}</span>}
|
||||
render={
|
||||
<span className="block max-w-[200px] cursor-default truncate font-semibold">
|
||||
{userId ? <EntityLink href={userDetailHref(userId)}>{displayValue}</EntityLink> : displayValue}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<HoverCardContent side="bottom" align="start" className="w-auto">
|
||||
{popoverContent}
|
||||
|
|
@ -265,6 +278,7 @@ export function KeyInfoHeader({
|
|||
label="Created By"
|
||||
value={data.createdBy}
|
||||
icon={<ShieldCheck className="size-3.5" />}
|
||||
href={data.createdById ? userDetailHref(data.createdById) : undefined}
|
||||
truncate
|
||||
copyable
|
||||
defaultUserIdCheck
|
||||
|
|
@ -277,6 +291,25 @@ export function KeyInfoHeader({
|
|||
<LabeledField label="Last Updated" value={data.lastUpdated} icon={<Clock className="size-3.5" />} />
|
||||
<LabeledField label="Last Active" value={data.lastActive} icon={<Zap className="size-3.5" />} />
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<LabeledField
|
||||
label="Team"
|
||||
value={data.teamAlias || data.teamId}
|
||||
icon={<Users className="size-3.5" />}
|
||||
href={data.teamId ? teamDetailHref(data.teamId) : undefined}
|
||||
truncate
|
||||
/>
|
||||
<LabeledField
|
||||
label="Organization"
|
||||
value={data.orgAlias || data.orgId}
|
||||
icon={<Building2 className="size-3.5" />}
|
||||
href={data.orgId ? orgDetailHref(data.orgId) : undefined}
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: () => <div data-testid="key-edit-view-stub" />,
|
||||
}));
|
||||
|
|
@ -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: [] }),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ const editViewMocks = vi.hoisted(() => ({
|
|||
onSubmit: undefined as ((v: Record<string, any>) => Promise<void>) | 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<string, any>) => Promise<void> }) => {
|
||||
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(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} 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(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} 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(
|
||||
<KeyInfoView keyData={keyData} onClose={() => {}} 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(
|
||||
<KeyInfoView
|
||||
keyData={{ ...MOCK_KEY_DATA, team_id: null }}
|
||||
onClose={() => {}}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Team ID</p>
|
||||
<p className="text-sm">{currentKeyData.team_id || "Not Set"}</p>
|
||||
<p className="text-sm">
|
||||
{currentKeyData.team_id ? (
|
||||
<EntityLink href={teamDetailHref(currentKeyData.team_id)} className="font-normal">
|
||||
{currentKeyData.team_id}
|
||||
</EntityLink>
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{enableProjectsUI && (
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue