litellm/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx
yuneng-jiang c794dcb91d
fix(ui): give status colours a readable foreground and drop the muted 70% step (#37649)
* fix(ui): give status colours a readable foreground and drop the muted 70% step

The four status tokens are lightened for dark mode, which is correct when they are used as text
and wrong for the 27 places that use them as a background under `text-white`. Every one of those
passes in light and fails in dark: success 1.78:1, warning 1.72:1, info 2.64:1, destructive
2.89:1. The cause is not 27 authoring mistakes, it is that no `--success-foreground` and no
sibling ever existed, so `text-white` was the only thing available to write. Adding the four
companions and registering them in `@theme` makes the correct pairing expressible, and the call
sites then read `text-success-foreground` instead of a hardcoded colour. Dark lands at 9.98, 10.31,
6.72 and 6.15.

Light is deliberately pure white rather than the near-white the other `-foreground` tokens use, so
the four ratios stay at exactly the 4.95, 5.03, 5.25 and 4.77 they are today instead of drifting
down to 4.73, 4.81, 5.02 and 4.56.

Separately `text-muted-foreground/70` measures 2.75:1 on a light page and 4.31:1 on a dark one,
so the same 183 occurrences fail AA in light and sit under it in dark. Dropping the opacity step
takes them to 4.84:1 and 7.34:1. The identical step on the placeholder base rule goes with them,
which is what put every input's placeholder at 2.75:1 in light.

Residual, not addressed here: `text-muted-foreground` over `bg-muted` reaches 4.39:1 in light,
still short of 4.5. Closing that needs `--muted-foreground` itself to move, which changes every
secondary label in the product and is a design call rather than a defect fix.

* fix(ui): finish the status-foreground swap and repoint no-op muted hovers

Four sites still forced text-white on a status fill because the class sat on
a child element rather than on the filled container, so the earlier sweep did
not reach them. The compliance quick-test bubble was worse: it paired bg-info
with text-success-foreground and its paragraph kept text-white on top, so the
dark-theme contrast the PR set out to fix was still reachable there

Dropping the /70 step also turned 21 existing "text-muted-foreground/70
hover:text-muted-foreground" pairs into hovers that change nothing, which
local/no-noop-hover-variant flags as an error. The affordance was "brighten on
hover", so these now hover to text-foreground, matching the 74 places that
already spell it that way

The remaining churn is prettier reflowing the handful of lines whose length
changed, since the token names are longer than text-white

* fix(ui): let the approve/reject confirm button pick the token its fill uses

Both submission review dialogs put text-success-foreground on the shared
button class while the fill below it swings between bg-success for Approve and
bg-destructive for Reject, so Reject drew a success token over a destructive
fill. The two tokens resolve to the same value today, so nothing looks wrong,
but the pairing only holds by coincidence and would break the moment either
token moves. Moving the token into the branch makes it track the fill

* fix(ui): drop the last 70% placeholders, still live on the legacy utility

Four inputs spell their placeholder colour with Tailwind's older
placeholder-<colour> utility rather than placeholder:text-<colour>, so the
sweep that dropped the 70% step passed over them. Tailwind 4.3 still emits
that utility, and utilities sit after base in the layer order, so those four
kept overriding the new input::placeholder rule and kept rendering at 70% in
dark mode, which is the contrast failure this PR set out to close

They now spell it the same way as the three placeholders the PR already
converted, which both removes the step and settles on one spelling
2026-08-20 10:58:32 -07:00

316 lines
9.8 KiB
TypeScript

import React from "react";
import {
ArrowLeft,
ArrowLeftRight,
Ban,
Building2,
Calendar,
CircleCheck,
Clock,
MoreVertical,
Plus,
RefreshCw,
ShieldCheck,
Timer,
Trash2,
User,
Users,
Zap,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
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";
export interface KeyInfoData {
keyName: string;
keyId: string;
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;
expires: string;
}
interface KeyInfoHeaderProps {
data: KeyInfoData;
onBack?: () => void;
onCreateNew?: () => void;
onRegenerate?: () => void;
onDelete?: () => void;
onResetSpend?: () => void;
onToggleBlocked?: () => void;
isBlocked?: boolean;
canModifyKey?: boolean;
backButtonText?: string;
regenerateDisabled?: boolean;
regenerateTooltip?: string;
}
function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null; userEmail: string; userId: string }) {
const labelEl = (
<div className="flex items-center gap-1">
<span className="text-muted-foreground">
<User className="size-3.5" />
</span>
<span className="text-xs uppercase tracking-[0.05em] text-muted-foreground">User</span>
</div>
);
const isEmpty = !userAlias && !userEmail && !userId;
if (isEmpty) {
return (
<div>
{labelEl}
<div>
<span className="font-semibold">-</span>
</div>
</div>
);
}
const isDefaultAdmin = userId === "default_user_id";
const displayValue = userAlias || userEmail || userId;
const popoverContent = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
{[
{ label: "User Alias", value: userAlias ?? null },
{ label: "User Email", value: userEmail || null },
{ label: "User ID", value: userId || null },
].map(({ label, value }) => (
<div key={label} className="flex flex-col min-w-0">
<span className="text-muted-foreground">{label}</span>
{value ? (
<div className="flex min-w-0 items-center gap-1">
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={value}>
{value}
</span>
<CopyButton value={value} label={`Copy ${label}`} iconClassName="size-3.5" />
</div>
) : (
<span className="font-mono">-</span>
)}
</div>
))}
</div>
);
if (isDefaultAdmin && !userAlias && !userEmail) {
return (
<div>
{labelEl}
<div>
<HoverCard>
<HoverCardTrigger
render={
<span className="cursor-default">
<DefaultProxyAdminTag userId={userId} />
</span>
}
/>
<HoverCardContent side="bottom" align="start" className="w-auto">
{popoverContent}
</HoverCardContent>
</HoverCard>
</div>
</div>
);
}
return (
<div>
{labelEl}
<div>
<HoverCard>
<HoverCardTrigger
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}
</HoverCardContent>
</HoverCard>
</div>
</div>
);
}
export function KeyInfoHeader({
data,
onBack,
onCreateNew,
onRegenerate,
onDelete,
onResetSpend,
onToggleBlocked,
isBlocked = false,
canModifyKey = true,
backButtonText = "Back to Keys",
regenerateDisabled = false,
regenerateTooltip,
}: KeyInfoHeaderProps) {
const regenerateButton = (
<span>
<Button variant="outline" onClick={onRegenerate} disabled={regenerateDisabled}>
<RefreshCw className="size-3.5" />
Regenerate Key
</Button>
</span>
);
return (
<div>
{onCreateNew && (
<div style={{ marginBottom: 16 }}>
<Button onClick={onCreateNew}>
<Plus className="size-3.5" />
Create New Key
</Button>
</div>
)}
<div style={{ marginBottom: 16 }}>
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="size-3.5" />
{backButtonText}
</Button>
</div>
<div className="flex items-start justify-between" style={{ marginBottom: 20 }}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="m-0 flex items-center gap-1 text-2xl font-semibold">
{data.keyName}
<CopyButton value={data.keyName} label="Copy Key Alias" iconClassName="size-4" />
</h3>
{isBlocked && (
<Badge variant="destructive">
<Ban className="size-3" />
Blocked
</Badge>
)}
</div>
<div className="flex min-w-0 items-center gap-1">
<span className="min-w-0 break-words text-muted-foreground">Key ID: {data.keyId}</span>
<CopyButton value={data.keyId} label="Copy Key ID" iconClassName="size-3.5" />
</div>
</div>
{canModifyKey && (
<div className="flex items-center gap-2">
{regenerateTooltip ? (
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger render={regenerateButton} />
<TooltipContent>{regenerateTooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
regenerateButton
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="More key actions" />}>
<MoreVertical className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
{onToggleBlocked &&
(isBlocked ? (
<DropdownMenuItem onClick={onToggleBlocked}>
<CircleCheck className="size-3.5" />
Unblock Key
</DropdownMenuItem>
) : (
<DropdownMenuItem variant="destructive" onClick={onToggleBlocked}>
<Ban className="size-3.5" />
Block Key
</DropdownMenuItem>
))}
{onResetSpend && (
<DropdownMenuItem variant="destructive" onClick={onResetSpend}>
<ArrowLeftRight className="size-3.5" />
Reset Spend
</DropdownMenuItem>
)}
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="size-3.5" />
Delete Key
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className="flex items-stretch gap-10" style={{ marginBottom: 40 }}>
<div className="flex min-w-0 flex-col gap-4">
<UserField userAlias={data.userAlias} userEmail={data.userEmail} userId={data.userId} />
<LabeledField label="Expires" value={data.expires} icon={<Timer className="size-3.5" />} />
</div>
<Separator orientation="vertical" />
<div className="flex min-w-0 flex-col gap-4">
<LabeledField label="Created At" value={data.createdAt} icon={<Calendar className="size-3.5" />} />
<LabeledField
label="Created By"
value={data.createdBy}
icon={<ShieldCheck className="size-3.5" />}
href={data.createdById ? userDetailHref(data.createdById) : undefined}
truncate
copyable
defaultUserIdCheck
/>
</div>
<Separator orientation="vertical" />
<div className="flex min-w-0 flex-col gap-4">
<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>
);
}