refactor(ui): move the settings page and bulk user invite onto shadcn primitives

Rebuilds settings.tsx and bulk_create_users_button.tsx on the in-repo shadcn
layer. The settings callback form moves from antd Form to react-hook-form with
the shared Field primitives, and the CSV drop zone replaces antd Upload with a
native file input plus drag handlers. Both public prop signatures are
unchanged, so no caller moves.
This commit is contained in:
Yuneng Jiang 2026-08-14 07:49:33 -07:00
parent 423b791ee0
commit 3170fff768
No known key found for this signature in database
5 changed files with 900 additions and 765 deletions

View file

@ -2295,9 +2295,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -3061,15 +3058,9 @@
"local/filename-pascal-case": {
"count": 1
},
"local/no-complex-jsx-arrow": {
"count": 4
},
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 3
},
"prefer-const": {
"count": 4
}

View file

@ -1,4 +1,5 @@
import { render } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import BulkCreateUsersButton from "./bulk_create_users_button";
@ -20,9 +21,55 @@ vi.mock("./molecules/notifications_manager", () => ({
},
}));
const csvFile = () =>
new File(["user_email,user_role\nnew.hire@example.com,internal_user\n"], "users.csv", { type: "text/csv" });
const openUploadStep = async () => {
const user = userEvent.setup();
render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
await user.click(screen.getByText("+ Bulk Invite Users"));
return user;
};
describe("BulkCreateUsersButton", () => {
it("should render", () => {
const { getByText } = render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
expect(getByText("+ Bulk Invite Users")).toBeInTheDocument();
});
it("parses a CSV chosen through the file input", async () => {
await openUploadStep();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [csvFile()] } });
expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument();
});
it("parses a CSV dropped onto the drop zone", async () => {
await openUploadStep();
const dropZone = screen.getByLabelText(/drag and drop your csv file here/i).closest("label");
fireEvent.drop(dropZone as HTMLLabelElement, { dataTransfer: { files: [csvFile()], types: ["Files"] } });
expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument();
});
it("exposes the drop zone as a label for a keyboard-reachable file input", async () => {
await openUploadStep();
const fileInput = screen.getByLabelText(/drag and drop your csv file here/i) as HTMLInputElement;
expect(fileInput).toHaveAttribute("type", "file");
expect(fileInput).toHaveAttribute("accept", ".csv");
expect(fileInput).toBeVisible();
const dropZone = fileInput.closest("label") as HTMLLabelElement;
expect(fileInput.id).not.toBe("");
expect(dropZone.htmlFor).toBe(fileInput.id);
const danglingLabels = [...document.querySelectorAll("label[for]")].filter(
(label) => document.getElementById(label.getAttribute("for") as string) === null,
);
expect(danglingLabels).toEqual([]);
});
});

View file

@ -1,14 +1,8 @@
import React, { useState, useEffect } from "react";
import { Text } from "@tremor/react";
import { Button, Modal, Table, Upload, Typography } from "antd";
import {
UploadOutlined,
DownloadOutlined,
WarningOutlined,
FileTextOutlined,
DeleteOutlined,
FileExclamationOutlined,
} from "@ant-design/icons";
import { Button, buttonVariants } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react";
import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking";
import Papa from "papaparse";
import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline";
@ -38,6 +32,8 @@ interface UserData {
invitation_link?: string;
}
const PREVIEW_PAGE_SIZE = 5;
// Define an interface for the UI settings
interface UISettings {
PROXY_BASE_URL: string | null;
@ -61,6 +57,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
const [baseUrl, setBaseUrl] = useState("http://localhost:4000");
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const csvInputId = React.useId();
useEffect(() => {
// Get UI settings
@ -93,7 +92,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`);
NotificationsManager.fromBackend("Invalid file type. Please upload a CSV file.");
return false;
return;
}
// Check file size (limit to 5MB)
@ -101,7 +100,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
setFileError(
`File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`,
);
return false;
return;
}
Papa.parse(file, {
@ -262,7 +261,27 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
},
header: false,
});
return false;
};
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDraggingOver(true);
};
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDraggingOver(false);
const file = event.dataTransfer.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const removeSelectedFile = () => {
@ -273,6 +292,12 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
setFileError(null);
};
const resetParsedData = () => {
setParsedData([]);
setParseError(null);
setPageIndex(0);
};
const handleBulkCreate = async () => {
setIsProcessing(true);
const updatedData = parsedData.map((user) => ({ ...user, status: "pending" }));
@ -434,340 +459,395 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
window.URL.revokeObjectURL(url);
};
const columns = [
{
title: "Row",
dataIndex: "rowNumber",
key: "rowNumber",
width: 80,
},
{
title: "Email",
dataIndex: "user_email",
key: "user_email",
},
{
title: "Role",
dataIndex: "user_role",
key: "user_role",
},
{
title: "Teams",
dataIndex: "teams",
key: "teams",
},
{
title: "Budget",
dataIndex: "max_budget",
key: "max_budget",
},
{
title: "Status",
key: "status",
render: (_: any, record: UserData) => {
if (!record.isValid) {
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Invalid</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{record.error}</span>}
</div>
);
}
if (!record.status || record.status === "pending") {
return <span className="text-gray-500">Pending</span>;
}
if (record.status === "success") {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
<span className="text-green-500">Success</span>
</div>
{record.invitation_link && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-gray-500 truncate max-w-[150px]">{record.invitation_link}</span>
<CopyToClipboard
text={record.invitation_link}
onCopy={() => NotificationsManager.success("Invitation link copied!")}
>
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">Copy</button>
</CopyToClipboard>
</div>
</div>
)}
</div>
);
}
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Failed</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{JSON.stringify(record.error)}</span>}
const renderStatusCell = (record: UserData) => {
if (!record.isValid) {
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Invalid</span>
</div>
);
},
},
];
{record.error && <span className="text-sm text-red-500 ml-7">{record.error}</span>}
</div>
);
}
if (!record.status || record.status === "pending") {
return <span className="text-gray-500">Pending</span>;
}
if (record.status === "success") {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
<span className="text-green-500">Success</span>
</div>
{record.invitation_link && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-gray-500 truncate max-w-[150px]">{record.invitation_link}</span>
<CopyToClipboard
text={record.invitation_link}
onCopy={() => NotificationsManager.success("Invitation link copied!")}
>
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">Copy</button>
</CopyToClipboard>
</div>
</div>
)}
</div>
);
}
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Failed</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{JSON.stringify(record.error)}</span>}
</div>
);
};
const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE));
const currentPage = Math.min(pageIndex, pageCount - 1);
const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE);
return (
<>
<Button type="primary" className="mb-0" onClick={() => setIsModalVisible(true)}>
<Button className="mb-0" onClick={() => setIsModalVisible(true)}>
+ Bulk Invite Users
</Button>
<Modal
title="Bulk Invite Users"
open={isModalVisible}
width={800}
onCancel={() => setIsModalVisible(false)}
bodyStyle={{ maxHeight: "70vh", overflow: "auto" }}
footer={null}
>
<div className="flex flex-col">
{/* Step indicator */}
{parsedData.length === 0 ? (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
1
</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
</ol>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_email</p>
<p className="text-sm text-gray-600">User&apos;s email address (required)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-gray-600">
User&apos;s role (one of: &quot;proxy_admin&quot;, &quot;proxy_admin_viewer&quot;,
&quot;internal_user&quot;, &quot;internal_user_viewer&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-gray-600">
Comma-separated team IDs (e.g., &quot;team-1,team-2&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., &quot;100&quot;)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-gray-600">
Budget reset period (e.g., &quot;30d&quot;, &quot;1mo&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">models</p>
<p className="text-sm text-gray-600">
Comma-separated allowed models (e.g., &quot;gpt-3.5-turbo,gpt-4&quot;)
</p>
</div>
</div>
<Dialog open={isModalVisible} onOpenChange={(open) => !open && setIsModalVisible(false)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Bulk Invite Users</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
{/* Step indicator */}
{parsedData.length === 0 ? (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
1
</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
</div>
<Button type="primary" size="large" className="w-full md:w-auto" icon={<DownloadOutlined />}>
Download CSV Template
</Button>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
</ol>
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
2
</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
</div>
<div className="ml-11">
{selectedFile ? (
<div
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
{fileError ? (
<FileExclamationOutlined className="text-red-500 text-xl mr-3" />
) : (
<FileTextOutlined className="text-blue-500 text-xl mr-3" />
)}
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<Typography.Text strong className={fileError ? "text-red-800" : "text-blue-800"}>
{selectedFile.name}
</Typography.Text>
<Typography.Text className={`block text-xs ${fileError ? "text-red-600" : "text-blue-600"}`}>
{(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()}
</Typography.Text>
<p className="font-medium">user_email</p>
<p className="text-sm text-gray-600">User&apos;s email address (required)</p>
</div>
</div>
<Button
size="small"
onClick={removeSelectedFile}
className="flex items-center"
icon={<DeleteOutlined />}
>
Remove
</Button>
</div>
{fileError ? (
<div className="mt-3 text-red-600 text-sm flex items-start">
<WarningOutlined className="mr-2 mt-0.5" />
<span>{fileError}</span>
</div>
) : (
!csvStructureError && (
<div className="mt-3 flex items-center">
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-blue-600">Processing...</span>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-gray-600">
User&apos;s role (one of: &quot;proxy_admin&quot;, &quot;proxy_admin_viewer&quot;,
&quot;internal_user&quot;, &quot;internal_user_viewer&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-gray-600">
Comma-separated team IDs (e.g., &quot;team-1,team-2&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., &quot;100&quot;)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-gray-600">
Budget reset period (e.g., &quot;30d&quot;, &quot;1mo&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">models</p>
<p className="text-sm text-gray-600">
Comma-separated allowed models (e.g., &quot;gpt-3.5-turbo,gpt-4&quot;)
</p>
</div>
)
)}
</div>
) : (
<Upload beforeUpload={handleFileUpload} accept=".csv" maxCount={1} showUploadList={false}>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer">
<UploadOutlined className="text-3xl text-gray-400 mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-gray-500 mb-3">or</p>
<Button size="small">Browse files</Button>
<p className="text-xs text-gray-500 mt-4">Only CSV files (.csv) are supported</p>
</div>
</Upload>
)}
{csvStructureError && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="flex items-start">
<ExclamationIcon className="h-5 w-5 text-yellow-500 mr-2 mt-0.5" />
<div>
<Typography.Text strong className="text-yellow-800">
CSV Structure Error
</Typography.Text>
<Typography.Paragraph className="text-yellow-700 mt-1 mb-0">
{csvStructureError}
</Typography.Paragraph>
<Typography.Paragraph className="text-yellow-700 mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
</Typography.Paragraph>
</div>
</div>
</div>
)}
</div>
</div>
) : (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
3
<Button size="lg" className="w-full md:w-auto">
<Download className="size-4" />
Download CSV Template
</Button>
</div>
<h3 className="text-lg font-medium">
{parsedData.some((user) => user.status === "success" || user.status === "failed")
? "User Creation Results"
: "Review and create users"}
</h3>
</div>
{parseError && (
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-start">
<WarningOutlined className="text-red-500 mr-2 mt-1" />
<div>
<Text className="text-red-600 font-medium">{parseError}</Text>
{parsedData.some((user) => !user.isValid) && (
<ul className="mt-2 list-disc list-inside text-red-600 text-sm">
<li>Check the table below for specific errors in each row</li>
<li>
Common issues include invalid email formats, missing required fields, or incorrect role
values
</li>
<li>Fix these issues in your CSV file and upload again</li>
</ul>
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
2
</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
</div>
<div className="ml-11">
{selectedFile ? (
<div
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center min-w-0">
{fileError ? (
<FileWarning className="size-5 shrink-0 text-red-500 mr-3" />
) : (
<FileText className="size-5 shrink-0 text-blue-500 mr-3" />
)}
<div className="min-w-0">
<strong className={`break-words ${fileError ? "text-red-800" : "text-blue-800"}`}>
{selectedFile.name}
</strong>
<span className={`block text-xs ${fileError ? "text-red-600" : "text-blue-600"}`}>
{(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()}
</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={removeSelectedFile} className="flex items-center">
<Trash2 className="size-4" />
Remove
</Button>
</div>
{fileError ? (
<div className="mt-3 text-red-600 text-sm flex items-start">
<TriangleAlert className="size-3.5 shrink-0 mr-2 mt-0.5" />
<span className="min-w-0 break-words">{fileError}</span>
</div>
) : (
!csvStructureError && (
<div className="mt-3 flex items-center">
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-blue-600">Processing...</span>
</div>
)
)}
</div>
</div>
</div>
)}
) : (
<label
htmlFor={csvInputId}
className="block"
onDragOver={handleDragOver}
onDragLeave={() => setIsDraggingOver(false)}
onDrop={handleDrop}
>
<div
className={`border-2 border-dashed ${isDraggingOver ? "border-blue-500" : "border-gray-300"} rounded-lg p-8 text-center hover:border-blue-500 focus-within:border-blue-500 transition-colors cursor-pointer`}
>
<input
id={csvInputId}
type="file"
accept=".csv"
className="sr-only"
onChange={handleFileInputChange}
/>
<Upload className="size-[30px] text-gray-400 mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-gray-500 mb-3">or</p>
<span className={buttonVariants({ variant: "outline", size: "sm" })}>Browse files</span>
<p className="text-xs text-gray-500 mt-4">Only CSV files (.csv) are supported</p>
</div>
</label>
)}
<div className="ml-11">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">Creation Summary</Text>
<Text className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded-sm mr-2">
{parsedData.filter((d) => d.status === "success").length} Successful
</Text>
{parsedData.some((d) => d.status === "failed") && (
<Text className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.status === "failed").length} Failed
</Text>
{csvStructureError && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="flex items-start">
<ExclamationIcon className="h-5 w-5 shrink-0 text-yellow-500 mr-2 mt-0.5" />
<div className="min-w-0">
<strong className="text-yellow-800">CSV Structure Error</strong>
<p className="text-yellow-700 mt-1 mb-0 break-words">{csvStructureError}</p>
<p className="text-yellow-700 mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
</p>
</div>
</div>
</div>
)}
</div>
</div>
) : (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
3
</div>
<h3 className="text-lg font-medium">
{parsedData.some((user) => user.status === "success" || user.status === "failed")
? "User Creation Results"
: "Review and create users"}
</h3>
</div>
{parseError && (
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-start">
<TriangleAlert className="size-4 shrink-0 text-red-500 mr-2 mt-1" />
<div className="min-w-0">
<p className="text-red-600 font-medium break-words">{parseError}</p>
{parsedData.some((user) => !user.isValid) && (
<ul className="mt-2 list-disc list-inside text-red-600 text-sm">
<li>Check the table below for specific errors in each row</li>
<li>
Common issues include invalid email formats, missing required fields, or incorrect role
values
</li>
<li>Fix these issues in your CSV file and upload again</li>
</ul>
)}
</div>
) : (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">User Preview</Text>
<Text className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
</Text>
</div>
</div>
)}
<div className="ml-11">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">Creation Summary</p>
<p className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded-sm mr-2">
{parsedData.filter((d) => d.status === "success").length} Successful
</p>
{parsedData.some((d) => d.status === "failed") && (
<p className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.status === "failed").length} Failed
</p>
)}
</div>
) : (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">User Preview</p>
<p className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
</p>
</div>
)}
</div>
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex space-x-3">
<Button variant="outline" onClick={resetParsedData}>
Back
</Button>
<Button
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
</Button>
</div>
)}
</div>
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex space-x-3">
{parsedData.some((user) => user.status === "success") && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-start">
<div className="mr-3 mt-1">
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
</div>
<div>
<p className="font-medium text-blue-800">User creation complete</p>
<p className="block text-sm text-blue-700 mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
</p>
</div>
</div>
</div>
)}
<div className="max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Row</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Teams</TableHead>
<TableHead>Budget</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((record) => (
<TableRow key={record.rowNumber} className={!record.isValid ? "bg-red-50" : ""}>
<TableCell>{record.rowNumber}</TableCell>
<TableCell className="whitespace-normal break-words">{record.user_email}</TableCell>
<TableCell className="whitespace-normal break-words">{record.user_role}</TableCell>
<TableCell className="whitespace-normal break-words">{record.teams}</TableCell>
<TableCell>{record.max_budget}</TableCell>
<TableCell className="whitespace-normal break-words">{renderStatusCell(record)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pageCount > 1 && (
<div className="flex items-center justify-end gap-3 mt-2">
<span className="text-sm text-gray-500">
Page {currentPage + 1} of {pageCount}
</span>
<Button
onClick={() => {
setParsedData([]);
setParseError(null);
}}
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPage - 1)}
disabled={currentPage === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPage + 1)}
disabled={currentPage >= pageCount - 1}
>
Next
</Button>
</div>
)}
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Back
</Button>
<Button
type="primary"
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
@ -775,77 +855,24 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
</Button>
</div>
)}
</div>
{parsedData.some((user) => user.status === "success") && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-start">
<div className="mr-3 mt-1">
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
</div>
<div>
<Text className="font-medium text-blue-800">User creation complete</Text>
<Text className="block text-sm text-blue-700 mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
</Text>
</div>
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Start New Bulk Import
</Button>
<Button onClick={downloadResults}>
<Download className="size-4" />
Download User Credentials
</Button>
</div>
</div>
)}
<Table
dataSource={parsedData}
columns={columns}
size="small"
pagination={{ pageSize: 5 }}
scroll={{ y: 300 }}
rowClassName={(record) => (!record.isValid ? "bg-red-50" : "")}
/>
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button
onClick={() => {
setParsedData([]);
setParseError(null);
}}
className="mr-3"
>
Back
</Button>
<Button
type="primary"
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
</Button>
</div>
)}
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button
onClick={() => {
setParsedData([]);
setParseError(null);
}}
className="mr-3"
>
Start New Bulk Import
</Button>
<Button type="primary" onClick={downloadResults} icon={<DownloadOutlined />}>
Download User Credentials
</Button>
</div>
)}
)}
</div>
</div>
</div>
)}
</div>
</Modal>
)}
</div>
</DialogContent>
</Dialog>
</>
);
};

View file

@ -1,8 +1,8 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "antd";
import { FormProvider, useForm } from "react-hook-form";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking";
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall, setCallbacksCall } from "./networking";
import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings";
vi.mock("./networking", () => ({
@ -114,42 +114,20 @@ describe("Settings", () => {
});
});
it("should display edit modal with fields when edit is clicked", async () => {
const mockCallback = {
name: "langfuse",
variables: {
LANGFUSE_PUBLIC_KEY: "test-public-key",
LANGFUSE_SECRET_KEY: "test-secret-key",
LANGFUSE_HOST: "https://test.langfuse.com",
SLACK_WEBHOOK_URL: null,
OPENMETER_API_KEY: null,
},
};
const mockCallbackConfig = {
id: "langfuse",
displayName: "Langfuse",
dynamic_params: {
LANGFUSE_PUBLIC_KEY: {
type: "text",
ui_name: "Public Key",
required: true,
},
LANGFUSE_SECRET_KEY: {
type: "password",
ui_name: "Secret Key",
required: true,
},
LANGFUSE_HOST: {
type: "text",
ui_name: "Host",
required: false,
},
},
};
const openLangfuseEditModal = async () => {
mockGetCallbacksCall.mockResolvedValue({
callbacks: [mockCallback],
callbacks: [
{
name: "langfuse",
variables: {
LANGFUSE_PUBLIC_KEY: "test-public-key",
LANGFUSE_SECRET_KEY: "test-secret-key",
LANGFUSE_HOST: "https://test.langfuse.com",
SLACK_WEBHOOK_URL: null,
OPENMETER_API_KEY: null,
},
},
],
available_callbacks: {
langfuse: {
litellm_callback_name: "langfuse",
@ -160,30 +138,118 @@ describe("Settings", () => {
alerts: [],
});
mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]);
mockGetCallbackConfigsCall.mockResolvedValue([
{
id: "langfuse",
displayName: "Langfuse",
dynamic_params: {
LANGFUSE_PUBLIC_KEY: { type: "text", ui_name: "Public Key", required: true },
LANGFUSE_SECRET_KEY: { type: "password", ui_name: "Secret Key", required: true },
LANGFUSE_HOST: { type: "text", ui_name: "Host", required: false },
},
},
]);
const user = userEvent.setup();
const { getByText } = render(<Settings {...defaultProps} />);
render(<Settings {...defaultProps} />);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText("Langfuse")).toBeInTheDocument();
expect(screen.getByText("Langfuse")).toBeInTheDocument();
});
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
await user.click(await screen.findByTestId("callback-action-edit"));
await waitFor(() => {
expect(getByText("Edit Callback Settings")).toBeInTheDocument();
expect(screen.getByText("Edit Callback Settings")).toBeInTheDocument();
});
return user;
};
it("should display edit modal with fields when edit is clicked", async () => {
await openLangfuseEditModal();
await waitFor(() => {
expect(screen.getByText("Public Key")).toBeInTheDocument();
expect(screen.getByText("Secret Key")).toBeInTheDocument();
expect(screen.getByText("Host")).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText("Public Key")).toBeInTheDocument();
expect(getByText("Secret Key")).toBeInTheDocument();
expect(getByText("Host")).toBeInTheDocument();
expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key");
});
expect(screen.getByLabelText("Secret Key")).toHaveValue("test-secret-key");
expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com");
const danglingLabels = [...document.querySelectorAll("label[for]")].filter(
(label) => document.getElementById(label.getAttribute("for") as string) === null,
);
expect(danglingLabels).toEqual([]);
});
it("should post the edited callback variables when the edit modal is saved", async () => {
const user = await openLangfuseEditModal();
await waitFor(() => {
expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com");
});
await user.clear(screen.getByLabelText("Host"));
await user.type(screen.getByLabelText("Host"), "https://edited.langfuse.com");
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", {
environment_variables: {
callback: "langfuse",
LANGFUSE_PUBLIC_KEY: "test-public-key",
LANGFUSE_SECRET_KEY: "test-secret-key",
LANGFUSE_HOST: "https://edited.langfuse.com",
},
litellm_settings: { success_callback: ["langfuse"] },
});
});
});
it("should block the edit submit when a required field is emptied", async () => {
const user = await openLangfuseEditModal();
await waitFor(() => {
expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key");
});
await user.clear(screen.getByLabelText("Public Key"));
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" }));
expect(await screen.findByText("Please enter the public key")).toBeInTheDocument();
expect(vi.mocked(setCallbacksCall)).not.toHaveBeenCalled();
});
it("should send the typed webhook url for an alert type when the alerting tab is saved", async () => {
const user = userEvent.setup();
render(<Settings {...defaultProps} />);
await user.click(await screen.findByRole("tab", { name: "Alerting Types" }));
const webhookInput = document.querySelector('input[name="llm_exceptions"]') as HTMLInputElement;
expect(webhookInput).not.toBeNull();
await user.type(webhookInput, "https://hooks.example.com/llm-exceptions");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", {
general_settings: expect.objectContaining({
alert_to_webhook_url: expect.objectContaining({
llm_exceptions: "https://hooks.example.com/llm-exceptions",
}),
}),
});
});
});
@ -252,6 +318,19 @@ describe("backendCallbackLogoSrc", () => {
});
});
const CallbackSelectorHarness = ({
callbackConfigs,
}: {
callbackConfigs: { id: string; displayName: string; logo?: string }[];
}) => {
const form = useForm<Record<string, string>>();
return (
<FormProvider {...form}>
<CallbackSelector callbackConfigs={callbackConfigs} selectedCallback={null} onCallbackChange={vi.fn()} />
</FormProvider>
);
};
describe("CallbackSelector logos", () => {
it("resolves backend logos per entry: bare filename, external url, and missing logo", async () => {
const callbackConfigs = [
@ -260,13 +339,9 @@ describe("CallbackSelector logos", () => {
{ id: "nologo", displayName: "NoLogo" },
];
render(
<Form>
<CallbackSelector callbackConfigs={callbackConfigs} selectedCallback={null} onCallbackChange={vi.fn()} />
</Form>,
);
render(<CallbackSelectorHarness callbackConfigs={callbackConfigs} />);
fireEvent.mouseDown(screen.getByRole("combobox"));
await userEvent.click(screen.getByRole("combobox"));
expect(await screen.findByAltText("Langfuse logo")).toHaveAttribute("src", "/ui/assets/logos/langfuse.png");
expect(screen.getByAltText("Hosted logo")).toHaveAttribute("src", "https://logos.example.com/hosted.png");

View file

@ -1,31 +1,26 @@
import {
Button,
Card,
Grid,
SelectItem,
Switch,
Tab,
TabGroup,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TabList,
TabPanel,
TabPanels,
Text,
TextInput,
} from "@tremor/react";
import React, { useEffect, useState } from "react";
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
import { Button as Button2, Form, Input, Modal, Select } from "antd";
import { Field, FieldError, FieldLabel } from "@/components/shared/form/field";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import EmailSettings from "./email_settings";
import { Logo } from "@/components/molecules/logo/Logo";
import NotificationsManager from "./molecules/notifications_manager";
import FormItem from "antd/es/form/FormItem";
import AlertingSettings from "./alerting/alerting_settings";
import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
@ -46,6 +41,8 @@ interface SettingsPageProps {
premiumUser: boolean;
}
type CallbackFormValues = Record<string, string>;
const assetsLogoFolder = "/ui/assets/logos/";
export const backendCallbackLogoSrc = (logo: string | null | undefined): string | undefined => {
@ -61,6 +58,9 @@ interface DynamicParamsFieldsProps {
}
const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callbackConfigs, selectedCallback }) => {
const { register, formState } = useFormContext<CallbackFormValues>();
const fieldIdPrefix = React.useId();
if (!params || params.length === 0) {
return null;
}
@ -73,54 +73,51 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
const paramType = paramConfig.type || "text";
const fieldLabel = paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
const isRequired = paramConfig.required || false;
const fieldId = `${fieldIdPrefix}-${param}`;
const registration = register(
param,
isRequired ? { required: `Please enter the ${fieldLabel.toLowerCase()}` } : undefined,
);
return (
<FormItem
label={<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>}
name={param}
key={param}
className="mb-4"
rules={
isRequired
? [
{
required: true,
message: `Please enter the ${fieldLabel.toLowerCase()}`,
},
]
: undefined
}
>
<Field key={param} className="mb-4">
<FieldLabel htmlFor={fieldId}>
<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>
</FieldLabel>
{paramType === "password" ? (
<Input.Password
size="large"
<Input
id={fieldId}
type="password"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
{...registration}
/>
) : paramType === "number" ? (
<Input
id={fieldId}
type="number"
size="large"
placeholder={`Enter ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
min={0}
max={1}
step={0.1}
{...registration}
/>
) : (
<Input
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
/>
<Input id={fieldId} placeholder={`Enter your ${fieldLabel.toLowerCase()}`} {...registration} />
)}
</FormItem>
<FieldError errors={[formState.errors[param]]} />
</Field>
);
})}
</div>
);
};
interface CallbackConfigOption {
id: string;
displayName: string;
logo?: string | null;
}
// Shared component for rendering callback selector
interface CallbackSelectorProps {
callbackConfigs: any[];
@ -135,42 +132,64 @@ export const CallbackSelector: React.FC<CallbackSelectorProps> = ({
onCallbackChange,
disabled = false,
}) => {
const { control } = useFormContext<CallbackFormValues>();
const inputId = React.useId();
const selectedConfig = callbackConfigs.find((config) => config.id === selectedCallback) ?? null;
return (
<FormItem
label="Callback"
<Controller
control={control}
name="callback"
rules={disabled ? undefined : [{ required: true, message: "Please select a callback" }]}
>
<Select
placeholder="Choose a logging callback..."
size="large"
className="w-full"
showSearch
disabled={disabled}
value={selectedCallback}
filterOption={(input, option) => {
return (option?.value?.toString() ?? "").toLowerCase().includes(input.toLowerCase());
}}
onChange={onCallbackChange}
>
{callbackConfigs.map((callbackConfig) => {
return (
<SelectItem key={callbackConfig.id} value={callbackConfig.id}>
<div className="flex items-center space-x-3 py-1">
<div className="w-6 h-6 flex items-center justify-center">
<Logo
src={backendCallbackLogoSrc(callbackConfig.logo)}
label={callbackConfig.displayName}
className="w-6 h-6 rounded-sm object-contain"
/>
</div>
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
</div>
</SelectItem>
);
})}
</Select>
</FormItem>
rules={disabled ? undefined : { required: "Please select a callback" }}
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor={inputId}>Callback</FieldLabel>
<Combobox
items={callbackConfigs}
value={selectedConfig}
onValueChange={(config: CallbackConfigOption | null) => {
field.onChange(config?.id ?? "");
onCallbackChange(config?.id ?? "");
}}
isItemEqualToValue={(a: CallbackConfigOption, b: CallbackConfigOption) => a.id === b.id}
itemToStringLabel={(config: CallbackConfigOption) => config.displayName}
filter={(config: CallbackConfigOption, query: string) =>
config.id.toLowerCase().includes(query.trim().toLowerCase())
}
disabled={disabled}
>
<ComboboxInput
id={inputId}
placeholder="Choose a logging callback..."
className="w-full"
disabled={disabled}
onBlur={field.onBlur}
aria-invalid={fieldState.error !== undefined || undefined}
/>
<ComboboxContent>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxList>
{(callbackConfig: CallbackConfigOption) => (
<ComboboxItem key={callbackConfig.id} value={callbackConfig}>
<div className="flex items-center space-x-3 py-1">
<div className="w-6 h-6 flex items-center justify-center">
<Logo
src={backendCallbackLogoSrc(callbackConfig.logo)}
label={callbackConfig.displayName}
className="w-6 h-6 rounded-sm object-contain"
/>
</div>
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
);
};
@ -206,8 +225,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true);
const [alerts, setAlerts] = useState<any[]>([]);
const [addForm] = Form.useForm();
const [editForm] = Form.useForm();
const addForm = useForm<CallbackFormValues>({ shouldUnregister: true });
const editForm = useForm<CallbackFormValues>({ shouldUnregister: true });
const [selectedCallback, setSelectedCallback] = useState<string | null>(null);
const [catchAllWebhookURL, setCatchAllWebhookURL] = useState<string>("");
const [alertToWebhooks, setAlertToWebhooks] = useState<Record<string, string>>({});
@ -254,7 +273,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
const normalized = Object.fromEntries(
Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]),
);
editForm.setFieldsValue({
editForm.reset({
...normalized,
callback: selectedEditCallback.name,
});
@ -337,11 +356,11 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
if (isEdit) {
setShowEditCallback(false);
editForm.resetFields();
editForm.reset();
setSelectedEditCallback(null);
} else {
setShowAddCallbacksModal(false);
addForm.resetFields();
addForm.reset();
setSelectedCallback(null);
setSelectedCallbackParams([]);
}
@ -383,6 +402,23 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setSelectedCallbackParams(params);
};
const closeAddCallbackModal = () => {
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
};
const cancelAddCallback = () => {
closeAddCallbackModal();
addForm.reset();
};
const closeEditCallbackModal = () => {
setShowEditCallback(false);
setSelectedEditCallback(null);
editForm.reset();
};
const handleSaveAlerts = async () => {
if (!accessToken) {
return;
@ -447,257 +483,216 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
return (
<div className="mx-4">
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
<TabGroup>
<TabList variant="line" defaultValue="1">
<Tab value="1">Logging Callbacks</Tab>
<Tab value="2">CloudZero Cost Tracking</Tab>
<Tab value="2">Alerting Types</Tab>
<Tab value="3">Alerting Settings</Tab>
<Tab value="4">Email Alerts</Tab>
</TabList>
<TabPanels>
<TabPanel>
<LoggingCallbacksTable
callbacks={callbacks}
availableCallbacks={allCallbacks}
isLoading={isLoadingCallbacks}
onAdd={() => setShowAddCallbacksModal(true)}
onEdit={(cb) => {
setSelectedEditCallback(cb);
setShowEditCallback(true);
}}
onDelete={(cb) => handleDeleteCallback(cb)}
onTest={async (cb) => {
try {
await serviceHealthCheck(accessToken, cb.name);
NotificationsManager.success("Health check triggered");
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
/>
</TabPanel>
<TabPanel>
<div className="p-8">
<CloudZeroCostTracking />
</div>
</TabPanel>
<TabPanel>
<Card>
<Text className="my-2">
Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
here
</a>
</Text>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell></TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
<TableHeaderCell>Slack Webhook URL</TableHeaderCell>
</TableRow>
</TableHead>
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
<Tabs defaultValue="logging-callbacks">
<TabsList variant="line">
<TabsTrigger value="logging-callbacks">Logging Callbacks</TabsTrigger>
<TabsTrigger value="cloudzero-cost-tracking">CloudZero Cost Tracking</TabsTrigger>
<TabsTrigger value="alerting-types">Alerting Types</TabsTrigger>
<TabsTrigger value="alerting-settings">Alerting Settings</TabsTrigger>
<TabsTrigger value="email-alerts">Email Alerts</TabsTrigger>
</TabsList>
<TabsContent value="logging-callbacks">
<LoggingCallbacksTable
callbacks={callbacks}
availableCallbacks={allCallbacks}
isLoading={isLoadingCallbacks}
onAdd={() => setShowAddCallbacksModal(true)}
onEdit={(cb) => {
setSelectedEditCallback(cb);
setShowEditCallback(true);
}}
onDelete={(cb) => handleDeleteCallback(cb)}
onTest={async (cb) => {
try {
await serviceHealthCheck(accessToken, cb.name);
NotificationsManager.success("Health check triggered");
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
/>
</TabsContent>
<TabsContent value="cloudzero-cost-tracking">
<div className="p-8">
<CloudZeroCostTracking />
</div>
</TabsContent>
<TabsContent value="alerting-types">
<Card className="p-6">
<p className="my-2">
Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
here
</a>
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Slack Webhook URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(alerts_to_UI_NAME).map(([key, value], index) => (
<TableRow key={index}>
<TableCell>
{key == "region_outage_alerts" ? (
premiumUser ? (
<Switch
id="switch"
name="switch"
checked={isAlertOn(key)}
onChange={() => handleSwitchChange(key)}
/>
) : (
<Button className="flex items-center justify-center">
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
✨ Enterprise Feature
</a>
</Button>
)
) : (
<TableBody>
{Object.entries(alerts_to_UI_NAME).map(([key, value], index) => (
<TableRow key={index}>
<TableCell>
{key == "region_outage_alerts" ? (
premiumUser ? (
<Switch
id="switch"
name="switch"
checked={isAlertOn(key)}
onChange={() => handleSwitchChange(key)}
onCheckedChange={() => handleSwitchChange(key)}
/>
)}
</TableCell>
<TableCell>
<Text>{value}</Text>
</TableCell>
<TableCell>
<TextInput
name={key}
type="password"
defaultValue={
alertToWebhooks && alertToWebhooks[key]
? alertToWebhooks[key]
: (catchAllWebhookURL as string)
}
></TextInput>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
Save Changes
</Button>
) : (
<Button className="flex items-center justify-center">
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
✨ Enterprise Feature
</a>
</Button>
)
) : (
<Switch
id="switch"
name="switch"
checked={isAlertOn(key)}
onCheckedChange={() => handleSwitchChange(key)}
/>
)}
</TableCell>
<TableCell className="whitespace-normal break-words">
<p>{value}</p>
</TableCell>
<TableCell>
<Input
name={key}
type="password"
defaultValue={
alertToWebhooks && alertToWebhooks[key]
? alertToWebhooks[key]
: (catchAllWebhookURL as string)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
Save Changes
</Button>
<Button
onClick={async () => {
try {
await serviceHealthCheck(accessToken, "slack");
NotificationsManager.success(
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
);
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
className="mx-2"
>
Test Alerts
</Button>
</Card>
</TabPanel>
<TabPanel>
<AlertingSettings accessToken={accessToken} premiumUser={premiumUser} />
</TabPanel>
<TabPanel>
<EmailSettings accessToken={accessToken} premiumUser={premiumUser} alerts={alerts} />
</TabPanel>
</TabPanels>
</TabGroup>
</Grid>
<Button
onClick={async () => {
try {
await serviceHealthCheck(accessToken, "slack");
NotificationsManager.success(
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
);
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
className="mx-2"
>
Test Alerts
</Button>
</Card>
</TabsContent>
<TabsContent value="alerting-settings">
<AlertingSettings accessToken={accessToken} premiumUser={premiumUser} />
</TabsContent>
<TabsContent value="email-alerts">
<EmailSettings accessToken={accessToken} premiumUser={premiumUser} alerts={alerts} />
</TabsContent>
</Tabs>
</div>
<Modal
title="Add Logging Callback"
open={showAddCallbacksModal}
width={800}
onCancel={() => {
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
}}
footer={null}
>
<a
href="https://docs.litellm.ai/docs/proxy/logging"
className="mb-8 mt-4"
target="_blank"
style={{ color: "blue" }}
>
{" "}
LiteLLM Docs: Logging
</a>
<Dialog open={showAddCallbacksModal} onOpenChange={(open) => !open && closeAddCallbackModal()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Add Logging Callback</DialogTitle>
</DialogHeader>
<a
href="https://docs.litellm.ai/docs/proxy/logging"
className="mb-8 mt-4"
target="_blank"
style={{ color: "blue" }}
>
{" "}
LiteLLM Docs: Logging
</a>
<Form
form={addForm}
onFinish={addNewCallbackCall}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
onCallbackChange={handleSelectedCallbackChange}
/>
<DynamicParamsFields
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
/>
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
onClick={() => {
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
addForm.resetFields();
}}
disabled={isAddingCallback}
>
Cancel
</Button2>
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
</Button2>
</div>
</Form>
</Modal>
<Modal
open={showEditCallback}
width={800}
title={"Edit Callback Settings"}
onCancel={() => {
setShowEditCallback(false);
setSelectedEditCallback(null);
editForm.resetFields();
}}
footer={null}
>
<Form
form={editForm}
onFinish={updateCallbackCall}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
{selectedEditCallback && (
<>
<FormProvider {...addForm}>
<form onSubmit={addForm.handleSubmit(addNewCallbackCall)}>
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
onCallbackChange={() => {}}
disabled={true}
selectedCallback={selectedCallback}
onCallbackChange={handleSelectedCallbackChange}
/>
<DynamicParamsFields
params={getDynamicParamsForCallback(
selectedEditCallback.name,
callbackConfigs,
selectedEditCallback.variables,
)}
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
selectedCallback={selectedCallback}
/>
</>
)}
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
onClick={() => {
setShowEditCallback(false);
setSelectedEditCallback(null);
editForm.resetFields();
}}
disabled={isUpdatingCallback}
>
Cancel
</Button2>
<Button2
onClick={() => {
editForm.submit();
}}
loading={isUpdatingCallback}
disabled={isUpdatingCallback}
>
{isUpdatingCallback ? "Saving..." : "Save Changes"}
</Button2>
</div>
</Form>
</Modal>
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button type="button" variant="outline" onClick={cancelAddCallback} disabled={isAddingCallback}>
Cancel
</Button>
<Button type="submit" disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
</Button>
</div>
</form>
</FormProvider>
</DialogContent>
</Dialog>
<Dialog open={showEditCallback} onOpenChange={(open) => !open && closeEditCallbackModal()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Edit Callback Settings</DialogTitle>
</DialogHeader>
<FormProvider {...editForm}>
<form onSubmit={editForm.handleSubmit(updateCallbackCall)}>
{selectedEditCallback && (
<>
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
onCallbackChange={() => {}}
disabled={true}
/>
<DynamicParamsFields
params={getDynamicParamsForCallback(
selectedEditCallback.name,
callbackConfigs,
selectedEditCallback.variables,
)}
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
/>
</>
)}
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button type="button" variant="outline" onClick={closeEditCallbackModal} disabled={isUpdatingCallback}>
Cancel
</Button>
<Button type="submit" disabled={isUpdatingCallback}>
{isUpdatingCallback ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</FormProvider>
</DialogContent>
</Dialog>
<DeleteResourceModal
isOpen={showDeleteConfirmModal}