feat(ui): common_components \u2014 AccessGroupSelector + DeleteResourceModal + PassThroughSecuritySection

- AccessGroupSelector: antd Select mode='multiple' with ant Skeleton
  loading state \u2192 shadcn Popover + chip-list multi-select (same
  pattern established in MCPServerSelector / VectorStoreSelector).
  Skeleton from shadcn.
- PassThroughSecuritySection: @tremor Card/Title/Subtitle/Text + antd
  Form.Item/Switch \u2192 shadcn Card/Switch + semantic Tailwind. Amber
  upgrade-prompt callout added to .eslintrc.json overrides.
- DeleteResourceModal: antd Modal/Alert/Card/Descriptions/Input/
  Typography/theme.useToken \u2192 shadcn AlertDialog/Card/Input + lucide
  AlertCircle. Resource information rendered as a two-column grid
  inside a destructive-tinted Card (replaces antd Descriptions).
  Confirmation input preserved; Delete action is disabled until the
  confirmation string matches.

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 10:40:24 +00:00
parent 4c8dc48b91
commit 591c2c035a
No known key found for this signature in database
4 changed files with 266 additions and 164 deletions

View file

@ -44,7 +44,9 @@
"src/components/shared/usage_date_picker.tsx",
"src/components/shared/advanced_date_picker.tsx",
"src/components/activity_metrics.tsx",
"src/components/skill_hub_table_columns.tsx"
"src/components/skill_hub_table_columns.tsx",
"src/components/common_components/PassThroughSecuritySection.tsx",
"src/components/common_components/DeleteResourceModal.tsx"
],
"rules": {
"litellm-ui/no-banned-ui-imports": "off",

View file

@ -1,7 +1,14 @@
import React from "react";
import { Select, Skeleton } from "antd";
import { TeamOutlined } from "@ant-design/icons";
import { Text } from "@tremor/react";
import React, { useMemo, useState } from "react";
import { Users, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import {
useAccessGroups,
AccessGroupResponse,
@ -16,17 +23,16 @@ export interface AccessGroupSelectorProps {
className?: string;
showLabel?: boolean;
labelText?: string;
/** Allow clearing the selection */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
allowClear?: boolean;
}
/**
* Reusable multi-select selector for access groups.
* Reusable multi-select for access groups (shadcn Popover + chip list).
*
* - Displays the **access_group_name** in the dropdown.
* - Returns an array of **access_group_id** values.
* - Always multi-select since users can assign multiple access groups.
* - Integrates with Ant Design `<Form.Item>` out of the box via `value` / `onChange`.
* - Displays the `access_group_name` in chips and options.
* - Returns an array of `access_group_id` values.
* - Drop-in replacement for the old antd Select mode='multiple'.
*/
const AccessGroupSelector: React.FC<AccessGroupSelectorProps> = ({
value,
@ -37,74 +43,134 @@ const AccessGroupSelector: React.FC<AccessGroupSelectorProps> = ({
className,
showLabel = false,
labelText = "Access Group",
allowClear = true,
}) => {
const { data: accessGroups, isLoading, isError } = useAccessGroups();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selected = value ?? [];
const allOptions = useMemo(
() =>
(accessGroups ?? []).map((group: AccessGroupResponse) => ({
id: group.access_group_id,
name: group.access_group_name,
searchText: `${group.access_group_name} ${group.access_group_id}`,
})),
[accessGroups],
);
const filteredOptions = useMemo(
() =>
allOptions
.filter((o) => !selected.includes(o.id))
.filter((o) =>
query
? o.searchText.toLowerCase().includes(query.toLowerCase())
: true,
),
[allOptions, selected, query],
);
const labelFor = (id: string) =>
allOptions.find((o) => o.id === id)?.name ?? id;
// ── Loading skeleton ─────────────────────────────────────────────────────
if (isLoading) {
return (
<div>
{showLabel && (
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<TeamOutlined className="mr-2" /> {labelText}
</Text>
<div className="font-medium mb-2 text-foreground flex items-center">
<Users className="mr-2 h-4 w-4" /> {labelText}
</div>
)}
<Skeleton.Input active block style={{ height: 32, ...style }} />
<Skeleton className="h-8 w-full" style={style} />
</div>
);
}
// ── Build options ────────────────────────────────────────────────────────
const options = (accessGroups ?? []).map((group: AccessGroupResponse) => ({
label: (
<span>
<span className="font-medium">{group.access_group_name}</span>{" "}
<span className="text-gray-400 text-xs">({group.access_group_id})</span>
</span>
),
value: group.access_group_id,
selectedLabel: group.access_group_name,
searchText: `${group.access_group_name} ${group.access_group_id}`,
}));
// ── Render ───────────────────────────────────────────────────────────────
return (
<div>
{showLabel && (
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<TeamOutlined className="mr-2" /> {labelText}
</Text>
<div className="font-medium mb-2 text-foreground flex items-center">
<Users className="mr-2 h-4 w-4" /> {labelText}
</div>
)}
<Select
mode="multiple"
value={value}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
allowClear={allowClear}
showSearch
style={{ width: "100%", ...style }}
className={`rounded-md ${className ?? ""}`}
notFoundContent={
isError ? (
<span className="text-red-500">Failed to load access groups</span>
) : (
"No access groups found"
)
}
filterOption={(input, option) => {
const searchText =
options.find((opt) => opt.value === option?.value)?.searchText ?? "";
return searchText.toLowerCase().includes(input.toLowerCase());
}}
optionLabelProp="selectedLabel"
options={options.map((opt) => ({
label: opt.label,
value: opt.value,
selectedLabel: opt.selectedLabel,
}))}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
style={style}
className={cn(
"min-h-9 w-full flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-left disabled:opacity-50",
className,
)}
>
{selected.length === 0 ? (
<span className="text-muted-foreground px-1">{placeholder}</span>
) : (
selected.map((id) => (
<Badge
key={id}
variant="secondary"
className="gap-1 inline-flex items-center"
>
{labelFor(id)}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange?.(selected.filter((s) => s !== id));
}}
className="inline-flex items-center"
aria-label={`Remove ${labelFor(id)}`}
>
<X size={12} />
</span>
</Badge>
))
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-2"
>
<Input
autoFocus
placeholder="Search access groups…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 mb-2"
/>
<div className="max-h-60 overflow-y-auto">
{isError ? (
<div className="py-2 px-3 text-sm text-destructive">
Failed to load access groups
</div>
) : filteredOptions.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No access groups found
</div>
) : (
filteredOptions.map((opt) => (
<button
key={opt.id}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent flex items-center gap-2"
onClick={() => onChange?.([...selected, opt.id])}
>
<span className="font-medium">{opt.name}</span>
<span className="text-muted-foreground text-xs">
({opt.id})
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
</div>
);
};

View file

@ -1,5 +1,16 @@
import { Alert, Card, Descriptions, Input, Modal, Typography, theme } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AlertCircle } from "lucide-react";
import React, { useState, useEffect } from "react";
interface DeleteResourceModalProps {
@ -8,12 +19,16 @@ interface DeleteResourceModalProps {
alertMessage?: string;
message: string;
resourceInformationTitle?: string;
resourceInformation?: Array<
{
label: string;
value: string | number | undefined | null;
} & Omit<React.ComponentProps<typeof Typography.Text>, "children">
>;
resourceInformation?: Array<{
label: string;
value: string | number | undefined | null;
// Kept for back-compat with callers that used to spread antd
// Typography.Text props (e.g. `type="danger"`, `strong`). Now only
// `className` is applied.
className?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}>;
onCancel: () => void;
onOk: () => void;
confirmLoading: boolean;
@ -32,80 +47,97 @@ export default function DeleteResourceModal({
confirmLoading,
requiredConfirmation,
}: DeleteResourceModalProps) {
const { Title, Text } = Typography;
const { token } = theme.useToken();
const [requiredConfirmationInput, setRequiredConfirmationInput] = useState("");
const [requiredConfirmationInput, setRequiredConfirmationInput] =
useState("");
useEffect(() => {
if (isOpen) {
setRequiredConfirmationInput("");
}
if (isOpen) setRequiredConfirmationInput("");
}, [isOpen]);
const confirmationBlocked =
!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation;
return (
<Modal
title={title}
<AlertDialog
open={isOpen}
onOk={onOk}
onCancel={onCancel}
confirmLoading={confirmLoading}
okText={confirmLoading ? "Deleting..." : "Delete"}
cancelText="Cancel"
okButtonProps={{
danger: true,
disabled: (!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading,
}}
cancelButtonProps={{ disabled: confirmLoading }}
onOpenChange={(o) => (!o ? onCancel() : undefined)}
>
<div className="space-y-4">
{alertMessage && <Alert message={alertMessage} type="warning" />}
<Card
title={resourceInformationTitle}
className="mt-4"
styles={{
body: { padding: "16px" },
header: {
backgroundColor: token.colorErrorBg,
borderColor: token.colorErrorBorder,
},
}}
style={{
backgroundColor: token.colorErrorBg,
borderColor: token.colorErrorBorder,
}}
>
<Descriptions column={1} size="small">
{resourceInformation &&
resourceInformation.map(({ label, value, ...textProps }) => (
<Descriptions.Item key={label} label={<span className="font-semibold">{label}</span>}>
<Text {...textProps}>{value ?? "-"}</Text>
</Descriptions.Item>
))}
</Descriptions>
</Card>
<div>
<Text>{message}</Text>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{/* Hoist alert + resource info up so the AlertDialogDescription
stays a plain string/paragraph (it must be a DIV-less element
inside <p> to satisfy Radix/a11y). */}
</AlertDialogHeader>
<div className="space-y-4">
{alertMessage && (
<div className="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 rounded-lg">
<p className="text-sm text-amber-800 dark:text-amber-200">
{alertMessage}
</p>
</div>
)}
{resourceInformationTitle || resourceInformation?.length ? (
<Card className="bg-destructive/5 border-destructive/30 p-0">
{resourceInformationTitle && (
<div className="px-4 py-2 border-b border-destructive/30 bg-destructive/10 rounded-t-md">
<span className="font-medium">
{resourceInformationTitle}
</span>
</div>
)}
<div className="p-4 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-sm">
{resourceInformation?.map(({ label, value, className }) => (
<React.Fragment key={label}>
<span className="font-semibold">{label}</span>
<span className={className}>{value ?? "-"}</span>
</React.Fragment>
))}
</div>
</Card>
) : null}
<AlertDialogDescription>{message}</AlertDialogDescription>
{requiredConfirmation && (
<div className="mb-2 mt-2 pt-3 border-t border-border">
<div className="block text-base font-medium text-foreground mb-2">
Type{" "}
<span className="font-bold text-destructive">
{requiredConfirmation}
</span>{" "}
to confirm deletion:
</div>
<div className="relative">
<AlertCircle className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-destructive pointer-events-none" />
<Input
value={requiredConfirmationInput}
onChange={(e) =>
setRequiredConfirmationInput(e.target.value)
}
placeholder={requiredConfirmation}
className="pl-7"
autoFocus
/>
</div>
</div>
)}
</div>
{requiredConfirmation && (
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Text className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
<Text>Type </Text>
<Text strong type="danger">
{requiredConfirmation}
</Text>
<Text> to confirm deletion:</Text>
</Text>
<Input
value={requiredConfirmationInput}
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
placeholder={requiredConfirmation}
className="rounded-md"
prefix={<ExclamationCircleOutlined style={{ color: token.colorError }} />}
autoFocus
/>
</div>
)}
</div>
</Modal>
<AlertDialogFooter>
<AlertDialogCancel disabled={confirmLoading}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={confirmationBlocked || confirmLoading}
onClick={(e) => {
e.preventDefault();
if (!confirmationBlocked) onOk();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{confirmLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -1,6 +1,6 @@
import React from "react";
import { Card, Title, Subtitle, Text } from "@tremor/react";
import { Form, Switch } from "antd";
import { Card } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
export interface PassThroughSecuritySectionProps {
premiumUser: boolean;
@ -8,10 +8,6 @@ export interface PassThroughSecuritySectionProps {
onAuthChange: (checked: boolean) => void;
}
/**
* Reusable Security section for pass-through endpoints
* Shows authentication toggle for premium users or upgrade message for free users
*/
const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({
premiumUser,
authEnabled,
@ -19,33 +15,39 @@ const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({
}) => {
return (
<Card className="p-6">
<Title className="text-lg font-semibold text-gray-900 mb-2">Security</Title>
<Subtitle className="text-gray-600 mb-4">
When enabled, requests to this endpoint will require a valid LiteLLM Virtual Key
</Subtitle>
<h3 className="text-lg font-semibold text-foreground mb-2">Security</h3>
<p className="text-muted-foreground mb-4">
When enabled, requests to this endpoint will require a valid LiteLLM
Virtual Key
</p>
{premiumUser ? (
<Form.Item name="auth" valuePropName="checked" className="mb-0">
<Switch
checked={authEnabled}
onChange={(checked) => {
onAuthChange(checked);
}}
/>
</Form.Item>
<Switch
name="auth"
checked={authEnabled}
onCheckedChange={(checked) => onAuthChange(checked)}
/>
) : (
<div>
<div className="flex items-center mb-3">
<Switch disabled checked={false} style={{ outline: "2px solid #d1d5db", outlineOffset: "2px" }} />
<span className="ml-2 text-sm text-gray-400">Authentication (Premium)</span>
<div className="flex items-center mb-3 gap-2">
<Switch disabled checked={false} />
<span className="text-sm text-muted-foreground">
Authentication (Premium)
</span>
</div>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<Text className="text-sm text-yellow-800">
Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key{" "}
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
<div className="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 rounded-lg">
<p className="text-sm text-amber-800 dark:text-amber-200">
Setting authentication for pass-through endpoints is a LiteLLM
Enterprise feature. Get a trial key{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
here
</a>
.
</Text>
</p>
</div>
</div>
)}