mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
4c8dc48b91
commit
591c2c035a
4 changed files with 266 additions and 164 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue