refactor(ui): migrate user, logging and policy forms to react-hook-form and shadcn (#37303)

* fix(ui): stop the policy modal cancelling its own save

The Create Policy and Update Policy buttons are Tremor buttons rendered
inside an antd Form. Tremor does not set a type, so both default to
type="submit" and a click ran two things at once: handleSubmit's own
form.validateFields(), and rc-field-form's onSubmit, which calls
formInstance.submit() and validates a second time.

rc-field-form keeps only the newest validation promise, so the first one
resolved as outOfDate and rejected with an empty errorFields list.
handleSubmit read that as a failure, so it never called createPolicy or
updatePolicy and instead reported "Failed to save policy". Creating and
editing a simple policy from the UI could not succeed.

Marking both footer buttons type="button" leaves the submit path solely
with handleSubmit. The new test file pins the request bodies for create
and update, and fails without this change.

* refactor(ui): migrate user, logging and policy forms to react-hook-form and shadcn

Moves four antd Form units onto react-hook-form plus the shadcn kit and
semantic color tokens, keeping every submit payload byte-identical.

- Settings/AdminSettings/LoggingSettings
- CreateUserButton
- users/_components/user_edit_view
- policies/_components/add_policy_form

* chore(ui): drop the eslint suppressions the migrated forms no longer need

* fix(ui): keep users with null optional fields editable

The proxy returns null rather than omitting user_alias, user_role,
budget_duration and metadata, and the new zod shape only allowed
undefined, so opening any such user and saving failed validation.
This commit is contained in:
yuneng-jiang 2026-08-18 11:42:12 -07:00 • committed by GitHub
parent 9ec0145986
commit 5e2d6addc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1727 additions and 821 deletions

View file

@ -1107,10 +1107,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"prefer-const": {
"count": 2
"count": 1
},
"react-hooks/immutability": {
"count": 2
@ -1516,9 +1513,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -1678,7 +1672,7 @@
},
"src/components/CreateUserButton.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1

View file

@ -0,0 +1,207 @@
import { cleanup, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
import type { Policy } from "@/components/policies/types";
import AddPolicyForm from "./add_policy_form";
vi.mock("@/components/networking", () => ({
getResolvedGuardrails: vi.fn().mockResolvedValue({ resolved_guardrails: [] }),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn().mockReturnValue({ userId: "u1", userRole: "Admin" }),
}));
const EXISTING_POLICY: Policy = {
policy_id: "pol-1",
policy_name: "existing-policy",
inherit: "parent-policy",
description: "an existing policy",
guardrails_add: ["guard-a"],
guardrails_remove: ["guard-b"],
condition: { model: "gpt-4" },
};
const PARENT_POLICY: Policy = {
policy_id: "pol-parent",
policy_name: "parent-policy",
inherit: null,
description: null,
guardrails_add: ["guard-c"],
guardrails_remove: [],
condition: null,
};
describe("AddPolicyForm", () => {
const createPolicy = vi.fn().mockResolvedValue({});
const updatePolicy = vi.fn().mockResolvedValue({});
const defaultProps = {
visible: true,
onClose: vi.fn(),
onSuccess: vi.fn(),
onOpenFlowBuilder: vi.fn(),
accessToken: "test-token",
existingPolicies: [PARENT_POLICY, EXISTING_POLICY],
availableGuardrails: [
{ guardrail_id: "g-a", guardrail_name: "guard-a" },
{ guardrail_id: "g-b", guardrail_name: "guard-b" },
{ guardrail_id: "g-c", guardrail_name: "guard-c" },
] as never,
createPolicy,
updatePolicy,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
const enterSimpleForm = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByRole("button", { name: "Create Policy" }));
};
it("should send exactly six keys with empty-to-undefined and empty-to-array defaults on create", async () => {
const user = userEvent.setup();
renderWithProviders(<AddPolicyForm {...defaultProps} />);
await enterSimpleForm(user);
await user.type(await screen.findByLabelText("Policy Name"), "brand-new-policy");
await user.click(screen.getByRole("button", { name: "Create Policy" }));
await waitFor(() => {
expect(createPolicy).toHaveBeenCalled();
});
const payload = createPolicy.mock.calls[0][1];
expect(Object.keys(payload).sort()).toEqual([
"condition",
"description",
"guardrails_add",
"guardrails_remove",
"inherit",
"policy_name",
]);
expect(payload).toStrictEqual({
policy_name: "brand-new-policy",
description: undefined,
inherit: undefined,
guardrails_add: [],
guardrails_remove: [],
condition: undefined,
});
});
it("should collapse a blank description to undefined rather than an empty string", async () => {
const user = userEvent.setup();
renderWithProviders(<AddPolicyForm {...defaultProps} />);
await enterSimpleForm(user);
const description = await screen.findByLabelText("Description");
await user.type(description, "x");
await user.clear(description);
await user.type(await screen.findByLabelText("Policy Name"), "blank-description");
await user.click(screen.getByRole("button", { name: "Create Policy" }));
await waitFor(() => {
expect(createPolicy).toHaveBeenCalled();
});
expect(createPolicy.mock.calls[0][1].description).toBeUndefined();
});
it("should send the seeded policy through updatePolicy with condition wrapped in a model object", async () => {
const user = userEvent.setup();
renderWithProviders(<AddPolicyForm {...defaultProps} editingPolicy={EXISTING_POLICY} />);
await user.click(await screen.findByRole("button", { name: "Update Policy" }));
await waitFor(() => {
expect(updatePolicy).toHaveBeenCalled();
});
expect(updatePolicy.mock.calls[0][0]).toBe("test-token");
expect(updatePolicy.mock.calls[0][1]).toBe("pol-1");
expect(updatePolicy.mock.calls[0][2]).toStrictEqual({
policy_name: "existing-policy",
description: "an existing policy",
inherit: "parent-policy",
guardrails_add: ["guard-a"],
guardrails_remove: ["guard-b"],
condition: { model: "gpt-4" },
});
expect(createPolicy).not.toHaveBeenCalled();
});
it("should keep the policy name field disabled while editing", async () => {
renderWithProviders(<AddPolicyForm {...defaultProps} editingPolicy={EXISTING_POLICY} />);
expect(await screen.findByLabelText("Policy Name")).toBeDisabled();
});
it("should block submission and call neither api when the policy name is missing", async () => {
const user = userEvent.setup();
renderWithProviders(<AddPolicyForm {...defaultProps} />);
await enterSimpleForm(user);
await user.click(await screen.findByRole("button", { name: "Create Policy" }));
expect(await screen.findByText("Please enter a policy name")).toBeInTheDocument();
expect(createPolicy).not.toHaveBeenCalled();
expect(updatePolicy).not.toHaveBeenCalled();
});
it("should block submission when the policy name has characters outside the allowed set", async () => {
const user = userEvent.setup();
renderWithProviders(<AddPolicyForm {...defaultProps} />);
await enterSimpleForm(user);
await user.type(await screen.findByLabelText("Policy Name"), "not a valid name!");
await user.click(screen.getByRole("button", { name: "Create Policy" }));
expect(
await screen.findByText("Policy name can only contain letters, numbers, hyphens, and underscores"),
).toBeInTheDocument();
expect(createPolicy).not.toHaveBeenCalled();
});
it("should swap the model condition label and clear the value when the condition type changes", async () => {
const user = userEvent.setup();
renderWithProviders(<AddPolicyForm {...defaultProps} editingPolicy={EXISTING_POLICY} />);
expect(await screen.findByLabelText("Model (Optional)")).toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: "Custom Regex Pattern" }));
const regexField = await screen.findByLabelText("Regex Pattern (Optional)");
expect(regexField).toHaveValue("");
expect(screen.queryByLabelText("Model (Optional)")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Update Policy" }));
await waitFor(() => {
expect(updatePolicy).toHaveBeenCalled();
});
expect(updatePolicy.mock.calls[0][2].condition).toBeUndefined();
});
it("should open the flow builder instead of the simple form when that mode is confirmed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onOpenFlowBuilder = vi.fn();
renderWithProviders(<AddPolicyForm {...defaultProps} onClose={onClose} onOpenFlowBuilder={onOpenFlowBuilder} />);
await user.click(await screen.findByText("Flow Builder"));
await user.click(screen.getByRole("button", { name: "Continue to Builder" }));
expect(onOpenFlowBuilder).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
expect(createPolicy).not.toHaveBeenCalled();
});
});

View file

@ -1,13 +1,25 @@
import React, { useState, useEffect } from "react";
import { Form, Select, Modal, Divider, Typography, Tag, Alert, Radio } from "antd";
import { Button, TextInput, Textarea } from "@tremor/react";
import { Modal, Alert, Tag } from "antd";
import { z } from "zod/v4";
import { Policy, PolicyCreateRequest, PolicyUpdateRequest } from "@/components/policies/types";
import { Guardrail } from "@/components/guardrails/types";
import { getResolvedGuardrails, modelAvailableCall } from "@/components/networking";
import { toast } from "@/lib/toast";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const { Text } = Typography;
import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import { CircleHelp } from "lucide-react";
interface AddPolicyFormProps {
visible: boolean;
@ -22,48 +34,123 @@ interface AddPolicyFormProps {
updatePolicy: (accessToken: string, policyId: string, policyData: any) => Promise<any>;
}
// ─────────────────────────────────────────────────────────────────────────────
// Mode Picker (Step 1) - shown first when creating a new policy
// ─────────────────────────────────────────────────────────────────────────────
type ModelConditionType = "model" | "regex";
interface ModePicker {
const policyShape = {
policy_name: z
.string()
.min(1, "Please enter a policy name")
.regex(/^[a-zA-Z0-9_-]+$/, "Policy name can only contain letters, numbers, hyphens, and underscores"),
description: z.string(),
inherit: z.string(),
guardrails_add: z.array(z.string()),
guardrails_remove: z.array(z.string()),
model_condition: z.string(),
};
const policySchema = z.object(policyShape);
type PolicyFormValues = z.infer<typeof policySchema>;
const EMPTY_VALUES: PolicyFormValues = {
policy_name: "",
description: "",
inherit: "",
guardrails_add: [],
guardrails_remove: [],
model_condition: "",
};
const toFormValues = (policy: Policy): PolicyFormValues => ({
policy_name: policy.policy_name,
description: policy.description ?? "",
inherit: policy.inherit ?? "",
guardrails_add: policy.guardrails_add || [],
guardrails_remove: policy.guardrails_remove || [],
model_condition: policy.condition?.model ?? "",
});
const buildPolicyRequest = (values: PolicyFormValues): PolicyCreateRequest | PolicyUpdateRequest => ({
policy_name: values.policy_name,
description: values.description || undefined,
inherit: values.inherit || undefined,
guardrails_add: values.guardrails_add,
guardrails_remove: values.guardrails_remove,
condition: values.model_condition ? { model: values.model_condition } : undefined,
});
const parentGuardrails = (policy: Policy, existingPolicies: Policy[]): string[] => {
const inherited = policy.inherit
? (() => {
const grandparent = existingPolicies.find((candidate) => candidate.policy_name === policy.inherit);
return grandparent ? parentGuardrails(grandparent, existingPolicies) : [];
})()
: [];
const resolved = new Set<string>([...inherited, ...(policy.guardrails_add ?? [])]);
(policy.guardrails_remove ?? []).forEach((guardrail) => resolved.delete(guardrail));
return Array.from(resolved);
};
const resolveGuardrails = (values: PolicyFormValues, existingPolicies: Policy[]): string[] => {
const parentPolicy = values.inherit
? existingPolicies.find((policy) => policy.policy_name === values.inherit)
: undefined;
const resolved = new Set<string>([
...(parentPolicy ? parentGuardrails(parentPolicy, existingPolicies) : []),
...values.guardrails_add,
]);
values.guardrails_remove.forEach((guardrail) => resolved.delete(guardrail));
return Array.from(resolved).sort();
};
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const SectionHeading: React.FC<{ label: string }> = ({ label }) => (
<div className="flex items-center gap-3 pt-2">
<span className="text-sm font-semibold text-foreground">{label}</span>
<Separator className="flex-1" />
</div>
);
interface ModePickerProps {
selected: "simple" | "flow_builder";
onSelect: (mode: "simple" | "flow_builder") => void;
}
const ModePicker: React.FC<ModePicker> = ({ selected, onSelect }) => (
<div className="flex gap-4" style={{ padding: "8px 0" }}>
{/* Simple Mode Card */}
<div
onClick={() => onSelect("simple")}
style={{
flex: 1,
padding: "24px 20px",
border: `2px solid ${selected === "simple" ? "#4f46e5" : "#e5e7eb"}`,
borderRadius: 12,
cursor: "pointer",
backgroundColor: selected === "simple" ? "#eef2ff" : "#fff",
transition: "all 0.15s ease",
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: selected === "simple" ? "#e0e7ff" : "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
}}
>
const modeCardClass = (isSelected: boolean) =>
[
"relative flex-1 cursor-pointer rounded-xl border-2 px-5 py-6 transition-all",
isSelected
? "border-indigo-600 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-950"
: "border-border bg-background",
].join(" ");
const modeIconClass = (isSelected: boolean) =>
[
"mb-4 flex size-10 items-center justify-center rounded-[10px]",
isSelected
? "bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-300"
: "bg-muted text-muted-foreground",
].join(" ");
const ModePicker: React.FC<ModePickerProps> = ({ selected, onSelect }) => (
<div className="flex gap-4 py-2">
<div onClick={() => onSelect("simple")} className={modeCardClass(selected === "simple")}>
<div className={modeIconClass(selected === "simple")}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke={selected === "simple" ? "#4f46e5" : "#6b7280"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
@ -72,59 +159,21 @@ const ModePicker: React.FC<ModePicker> = ({ selected, onSelect }) => (
<path d="M8 7h8M8 12h8M8 17h5" />
</svg>
</div>
<Text strong style={{ fontSize: 15, display: "block", marginBottom: 4 }}>
Simple Mode
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Pick guardrails from a list. All run in parallel.
</Text>
<span className="mb-1 block text-[15px] font-semibold text-foreground">Simple Mode</span>
<span className="block text-[13px] text-muted-foreground">Pick guardrails from a list. All run in parallel.</span>
</div>
{/* Flow Builder Card */}
<div
onClick={() => onSelect("flow_builder")}
style={{
flex: 1,
padding: "24px 20px",
border: `2px solid ${selected === "flow_builder" ? "#4f46e5" : "#e5e7eb"}`,
borderRadius: 12,
cursor: "pointer",
backgroundColor: selected === "flow_builder" ? "#eef2ff" : "#fff",
transition: "all 0.15s ease",
position: "relative",
}}
>
<Tag
color="purple"
style={{
position: "absolute",
top: 12,
right: 12,
fontSize: 10,
fontWeight: 600,
margin: 0,
}}
>
<div onClick={() => onSelect("flow_builder")} className={modeCardClass(selected === "flow_builder")}>
<Badge variant="secondary" className="absolute top-3 right-3 text-[10px] font-semibold">
NEW
</Tag>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: selected === "flow_builder" ? "#e0e7ff" : "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
}}
>
</Badge>
<div className={modeIconClass(selected === "flow_builder")}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke={selected === "flow_builder" ? "#4f46e5" : "#6b7280"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
@ -132,20 +181,12 @@ const ModePicker: React.FC<ModePicker> = ({ selected, onSelect }) => (
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</div>
<Text strong style={{ fontSize: 15, display: "block", marginBottom: 4 }}>
Flow Builder
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Define steps, conditions, and error responses.
</Text>
<span className="mb-1 block text-[15px] font-semibold text-foreground">Flow Builder</span>
<span className="block text-[13px] text-muted-foreground">Define steps, conditions, and error responses.</span>
</div>
</div>
);
// ─────────────────────────────────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────────────────────────────────
const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
visible,
onClose,
@ -158,10 +199,10 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
createPolicy,
updatePolicy,
}) => {
const [form] = Form.useForm();
const form = useZodForm(policySchema, { defaultValues: EMPTY_VALUES });
const [isSubmitting, setIsSubmitting] = useState(false);
const [resolvedGuardrails, setResolvedGuardrails] = useState<string[]>([]);
const [modelConditionType, setModelConditionType] = useState<"model" | "regex">("model");
const [modelConditionType, setModelConditionType] = useState<ModelConditionType>("model");
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [step, setStep] = useState<"pick_mode" | "simple_form">("pick_mode");
const [selectedMode, setSelectedMode] = useState<"simple" | "flow_builder">("simple");
@ -176,14 +217,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
const isRegex = modelCondition && /[.*+?^${}()|[\]\\]/.test(modelCondition);
setModelConditionType(isRegex ? "regex" : "model");
form.setFieldsValue({
policy_name: editingPolicy.policy_name,
description: editingPolicy.description,
inherit: editingPolicy.inherit,
guardrails_add: editingPolicy.guardrails_add || [],
guardrails_remove: editingPolicy.guardrails_remove || [],
model_condition: modelCondition,
});
form.reset(toFormValues(editingPolicy));
if (editingPolicy.policy_id && accessToken) {
loadResolvedGuardrails(editingPolicy.policy_id);
@ -198,7 +232,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
// If editing a simple policy, skip mode picker
setStep("simple_form");
} else if (visible) {
form.resetFields();
form.reset(EMPTY_VALUES);
setResolvedGuardrails([]);
setModelConditionType("model");
setSelectedMode("simple");
@ -237,56 +271,12 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
}
};
const computeResolvedGuardrails = (): string[] => {
const values = form.getFieldsValue(true);
const inheritFrom = values.inherit;
const guardrailsAdd = values.guardrails_add || [];
const guardrailsRemove = values.guardrails_remove || [];
let resolved = new Set<string>();
if (inheritFrom) {
const parentPolicy = existingPolicies.find((p) => p.policy_name === inheritFrom);
if (parentPolicy) {
const parentResolved = resolveParentGuardrails(parentPolicy);
parentResolved.forEach((g) => resolved.add(g));
}
}
guardrailsAdd.forEach((g: string) => resolved.add(g));
guardrailsRemove.forEach((g: string) => resolved.delete(g));
return Array.from(resolved).sort();
};
const resolveParentGuardrails = (policy: Policy): string[] => {
let resolved = new Set<string>();
if (policy.inherit) {
const grandparent = existingPolicies.find((p) => p.policy_name === policy.inherit);
if (grandparent) {
resolveParentGuardrails(grandparent).forEach((g) => resolved.add(g));
}
}
if (policy.guardrails_add) {
policy.guardrails_add.forEach((g) => resolved.add(g));
}
if (policy.guardrails_remove) {
policy.guardrails_remove.forEach((g) => resolved.delete(g));
}
return Array.from(resolved);
};
const handleFormChange = () => {
setResolvedGuardrails(computeResolvedGuardrails());
};
const resetForm = () => {
form.resetFields();
const refreshResolvedGuardrails = (changed: Partial<PolicyFormValues>) => {
setResolvedGuardrails(resolveGuardrails({ ...form.getValues(), ...changed }, existingPolicies));
};
const handleClose = () => {
resetForm();
form.reset(EMPTY_VALUES);
setStep("pick_mode");
setSelectedMode("simple");
onClose();
@ -301,24 +291,15 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
}
};
const handleSubmit = async () => {
const handleSubmit = async (values: PolicyFormValues) => {
try {
setIsSubmitting(true);
await form.validateFields();
const values = form.getFieldsValue(true);
if (!accessToken) {
throw new Error("No access token available");
}
const data: PolicyCreateRequest | PolicyUpdateRequest = {
policy_name: values.policy_name,
description: values.description || undefined,
inherit: values.inherit || undefined,
guardrails_add: values.guardrails_add || [],
guardrails_remove: values.guardrails_remove || [],
condition: values.model_condition ? { model: values.model_condition } : undefined,
};
const data = buildPolicyRequest(values);
if (isEditing && editingPolicy) {
await updatePolicy(accessToken, editingPolicy.policy_id, data as PolicyUpdateRequest);
@ -328,7 +309,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
toast.success("Policy created successfully");
}
resetForm();
form.reset(EMPTY_VALUES);
onSuccess();
onClose();
} catch (error) {
@ -361,26 +342,15 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
<Alert
message="You'll be redirected to the full-screen Flow Builder to design your policy logic visually."
type="info"
style={{
marginTop: 16,
backgroundColor: "#eef2ff",
border: "1px solid #c7d2fe",
}}
className="mt-4 border border-indigo-200 bg-indigo-50 dark:border-indigo-800 dark:bg-indigo-950"
/>
)}
<div className="flex justify-end gap-2" style={{ marginTop: 24 }}>
<Button variant="secondary" onClick={handleClose}>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleModeConfirm}
style={{
backgroundColor: "#4f46e5",
color: "#fff",
border: "none",
}}
>
<Button type="button" onClick={handleModeConfirm}>
{selectedMode === "flow_builder" ? "Continue to Builder" : "Create Policy"}
</Button>
</div>
@ -397,165 +367,192 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
footer={null}
width={700}
>
<Form
form={form}
layout="vertical"
initialValues={{
guardrails_add: [],
guardrails_remove: [],
}}
onValuesChange={handleFormChange}
>
<Form.Item
name="policy_name"
label="Policy Name"
rules={[
{ required: true, message: "Please enter a policy name" },
{
pattern: /^[a-zA-Z0-9_-]+$/,
message: "Policy name can only contain letters, numbers, hyphens, and underscores",
},
]}
>
<TextInput placeholder="e.g., global-baseline, healthcare-compliance" disabled={isEditing} />
</Form.Item>
<TooltipProvider>
<form onSubmit={(event) => event.preventDefault()} noValidate>
<FieldGroup>
<FormField control={form.control} name="policy_name" label="Policy Name">
{({ ref, ...control }) => (
<Input
{...control}
ref={ref}
placeholder="e.g., global-baseline, healthcare-compliance"
disabled={isEditing}
/>
)}
</FormField>
<Form.Item name="description" label="Description">
<Textarea rows={2} placeholder="Describe what this policy does..." />
</Form.Item>
<FormField control={form.control} name="description" label="Description">
{({ ref, ...control }) => (
<Textarea {...control} ref={ref} rows={2} placeholder="Describe what this policy does..." />
)}
</FormField>
<Divider orientation="left">
<Text strong>Inheritance</Text>
</Divider>
<SectionHeading label="Inheritance" />
<Form.Item
name="inherit"
label="Inherit From"
tooltip="Inherit guardrails from another policy. The child policy will include all guardrails from the parent."
>
<Select
allowClear
placeholder="Select a parent policy (optional)"
options={policyOptions}
style={{ width: "100%" }}
/>
</Form.Item>
<FormField
control={form.control}
name="inherit"
label={labelWithHint(
"Inherit From",
"Inherit guardrails from another policy. The child policy will include all guardrails from the parent.",
)}
>
{({ id, value, onChange }) => (
<SearchSelect
inputId={id}
options={policyOptions}
value={value}
onValueChange={(selected) => {
onChange(selected);
refreshResolvedGuardrails({ inherit: selected });
}}
placeholder="Select a parent policy (optional)"
className="h-9"
/>
)}
</FormField>
<Divider orientation="left">
<Text strong>Guardrails</Text>
</Divider>
<SectionHeading label="Guardrails" />
<Form.Item
name="guardrails_add"
label="Guardrails to Add"
tooltip="These guardrails will be added to requests matching this policy"
>
<Select
mode="multiple"
allowClear
placeholder="Select guardrails to add"
options={guardrailOptions}
style={{ width: "100%" }}
/>
</Form.Item>
<FormField
control={form.control}
name="guardrails_add"
label={labelWithHint(
"Guardrails to Add",
"These guardrails will be added to requests matching this policy",
)}
>
{({ value, onChange }) => (
<MultiSelect
options={guardrailOptions}
value={value}
onValueChange={(selected) => {
onChange(selected);
refreshResolvedGuardrails({ guardrails_add: selected });
}}
placeholder="Select guardrails to add"
/>
)}
</FormField>
<Form.Item
name="guardrails_remove"
label="Guardrails to Remove"
tooltip="These guardrails will be removed from inherited guardrails"
>
<Select
mode="multiple"
allowClear
placeholder="Select guardrails to remove (from inherited)"
options={guardrailOptions}
style={{ width: "100%" }}
/>
</Form.Item>
<FormField
control={form.control}
name="guardrails_remove"
label={labelWithHint(
"Guardrails to Remove",
"These guardrails will be removed from inherited guardrails",
)}
>
{({ value, onChange }) => (
<MultiSelect
options={guardrailOptions}
value={value}
onValueChange={(selected) => {
onChange(selected);
refreshResolvedGuardrails({ guardrails_remove: selected });
}}
placeholder="Select guardrails to remove (from inherited)"
/>
)}
</FormField>
{resolvedGuardrails.length > 0 && (
<Alert
message="Resolved Guardrails"
description={
<div>
<Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
These are the final guardrails that will be applied (including inheritance):
</Text>
<div className="flex flex-wrap gap-1">
{resolvedGuardrails.map((g) => (
<Tag key={g} color="blue">
{g}
</Tag>
))}
</div>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{resolvedGuardrails.length > 0 && (
<Alert
message="Resolved Guardrails"
description={
<div>
<span className="mb-2 block text-muted-foreground">
These are the final guardrails that will be applied (including inheritance):
</span>
<div className="flex flex-wrap gap-1">
{resolvedGuardrails.map((g) => (
<Tag key={g} color="blue">
{g}
</Tag>
))}
</div>
</div>
}
type="info"
showIcon
/>
)}
<Divider orientation="left">
<Text strong>Conditions (Optional)</Text>
</Divider>
<SectionHeading label="Conditions (Optional)" />
<Alert
message="Model Scope"
description="By default, this policy will run on all models. You can optionally restrict it to specific models below."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item label="Model Condition Type">
<Radio.Group
value={modelConditionType}
onChange={(e) => {
setModelConditionType(e.target.value);
form.setFieldValue("model_condition", undefined);
}}
>
<Radio value="model">Select Model</Radio>
<Radio value="regex">Custom Regex Pattern</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="model_condition"
label={modelConditionType === "model" ? "Model (Optional)" : "Regex Pattern (Optional)"}
tooltip={
modelConditionType === "model"
? "Select a specific model to apply this policy to. Leave empty to apply to all models."
: "Enter a regex pattern to match models (e.g., gpt-4.* or bedrock/.*). Leave empty to apply to all models."
}
>
{modelConditionType === "model" ? (
<Select
showSearch
allowClear
placeholder="Leave empty to apply to all models"
options={availableModels.map((model) => ({
label: model,
value: model,
}))}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
style={{ width: "100%" }}
<Alert
message="Model Scope"
description="By default, this policy will run on all models. You can optionally restrict it to specific models below."
type="info"
showIcon
/>
) : (
<TextInput placeholder="Leave empty to apply to all models (e.g., gpt-4.* or bedrock/claude-.*)" />
)}
</Form.Item>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={isSubmitting}>
{isEditing ? "Update Policy" : "Create Policy"}
</Button>
</div>
</Form>
<div role="group" className="flex w-full flex-col gap-3">
<span className="text-sm leading-snug font-medium text-foreground">Model Condition Type</span>
<RadioGroup
value={modelConditionType}
onValueChange={(value) => {
setModelConditionType(value as ModelConditionType);
form.setValue("model_condition", "");
}}
className="flex flex-row gap-6"
>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="model" />
Select Model
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="regex" />
Custom Regex Pattern
</label>
</RadioGroup>
</div>
<FormField
control={form.control}
name="model_condition"
label={labelWithHint(
modelConditionType === "model" ? "Model (Optional)" : "Regex Pattern (Optional)",
modelConditionType === "model"
? "Select a specific model to apply this policy to. Leave empty to apply to all models."
: "Enter a regex pattern to match models (e.g., gpt-4.* or bedrock/.*). Leave empty to apply to all models.",
)}
>
{({ ref, id, value, onChange, ...control }) =>
modelConditionType === "model" ? (
<SearchSelect
inputId={id}
options={availableModels.map((model) => ({ label: model, value: model }))}
value={value}
onValueChange={onChange}
placeholder="Leave empty to apply to all models"
className="h-9"
/>
) : (
<Input
{...control}
id={id}
ref={ref}
value={value}
onChange={onChange}
placeholder="Leave empty to apply to all models (e.g., gpt-4.* or bedrock/claude-.*)"
/>
)
}
</FormField>
</FieldGroup>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="button" onClick={form.handleSubmit(handleSubmit)} disabled={isSubmitting}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{isEditing ? "Update Policy" : "Create Policy"}
</Button>
</div>
</form>
</TooltipProvider>
</Modal>
);
};

View file

@ -524,4 +524,179 @@ describe("UserEditView", () => {
expect(checkbox).toBeChecked();
});
});
describe("submit payload parity", () => {
const submittedPayload = async (props: Partial<Parameters<typeof UserEditView>[0]> = {}) => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} {...props} onSubmit={onSubmit} />);
await userEvent.click(await screen.findByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
return onSubmit.mock.calls[0][0];
};
it("should send exactly the ten keys an admin edit produces, with seeded types preserved", async () => {
const payload = await submittedPayload();
expect(Object.keys(payload).sort()).toEqual([
"budget_duration",
"max_budget",
"mcp_servers_and_groups",
"mcp_tool_permissions",
"metadata",
"models",
"user_alias",
"user_email",
"user_id",
"user_role",
]);
expect(payload).toStrictEqual({
user_id: "user-123",
user_email: "test@example.com",
user_alias: "Test User",
user_role: "proxy_admin",
models: ["gpt-4", "gpt-3.5-turbo"],
max_budget: 100.5,
budget_duration: "30d",
metadata: { key1: "value1", key2: "value2" },
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
mcp_tool_permissions: {},
});
expect(typeof payload.max_budget).toBe("number");
});
it("should drop user_id, user_email and both mcp keys in bulk edit mode", async () => {
const payload = await submittedPayload({ isBulkEdit: true });
expect(Object.keys(payload).sort()).toEqual([
"budget_duration",
"max_budget",
"metadata",
"models",
"user_alias",
"user_role",
]);
});
it("should drop both mcp keys for a non-admin editor while keeping identity keys", async () => {
const payload = await submittedPayload({ userRole: "user" });
expect(Object.keys(payload).sort()).toEqual([
"budget_duration",
"max_budget",
"metadata",
"models",
"user_alias",
"user_email",
"user_id",
"user_role",
]);
});
it("should send a typed budget as a string, not a number", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} onSubmit={onSubmit} />);
const budgetInput = await screen.findByRole("spinbutton", { name: /max budget/i });
await userEvent.clear(budgetInput);
await userEvent.type(budgetInput, "42.57");
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0].max_budget).toBe("42.57");
});
it("should still submit when the loaded user has null instead of missing optional fields", async () => {
const onSubmit = vi.fn();
renderWithProviders(
<UserEditView
{...defaultProps}
onSubmit={onSubmit}
userData={{
user_id: "user-null",
user_info: {
user_email: "null@example.com",
user_alias: null,
user_role: null,
models: null,
max_budget: null,
budget_duration: null,
metadata: null,
},
}}
/>,
);
await userEvent.click(await screen.findByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0]).toMatchObject({
user_id: "user-null",
user_email: "null@example.com",
user_alias: null,
user_role: null,
budget_duration: null,
max_budget: null,
});
});
it("should keep the budget input's native step constraint armed", async () => {
renderWithProviders(<UserEditView {...defaultProps} />);
const budgetInput = await screen.findByRole("spinbutton", { name: /max budget/i });
expect(budgetInput).toHaveAttribute("step", "0.01");
expect(budgetInput).not.toHaveAttribute("min");
expect(budgetInput.closest("form")).not.toHaveAttribute("novalidate");
});
it("should send objects for the mcp keys seeded from objectPermission", async () => {
const payload = await submittedPayload({
objectPermission: {
mcp_servers: ["server-a"],
mcp_access_groups: ["group-a"],
mcp_toolsets: ["toolset-a"],
mcp_tool_permissions: { "server-a": ["tool-a"] },
} as never,
});
expect(payload.mcp_servers_and_groups).toStrictEqual({
servers: ["server-a"],
accessGroups: ["group-a"],
toolsets: ["toolset-a"],
});
expect(payload.mcp_tool_permissions).toStrictEqual({ "server-a": ["tool-a"] });
});
it("should not submit at all when metadata is not valid JSON", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} onSubmit={onSubmit} />);
const metadata = await screen.findByLabelText("Metadata");
await userEvent.clear(metadata);
await userEvent.type(metadata, "not json");
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(screen.getByLabelText("Metadata")).toHaveValue("not json");
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("should send an empty-string metadata through untouched rather than as an object", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} onSubmit={onSubmit} />);
await userEvent.clear(await screen.findByLabelText("Metadata"));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0].metadata).toBe("");
});
});
});

View file

@ -1,14 +1,22 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, SelectItem, TextInput, Textarea } from "@tremor/react";
import { Checkbox, Form, Input, Select, Tooltip } from "antd";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { z } from "zod/v4";
import { all_admin_roles } from "@/utils/roles";
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import NumericalInput from "@/components/shared/numerical_input";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
import type { ObjectPermission } from "@/components/object_permission_types";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useZodForm } from "@/lib/forms/useZodForm";
import { CircleHelp } from "lucide-react";
interface UserEditViewProps {
userData: any;
@ -24,6 +32,41 @@ interface UserEditViewProps {
objectPermission?: ObjectPermission | null;
}
const MCP_SELECTION_SHAPE = z.object({
servers: z.array(z.string()),
accessGroups: z.array(z.string()),
toolsets: z.array(z.string()),
});
// The proxy stores unset user fields as null rather than leaving them out, and
// antd forwarded whatever it was handed, so nullish is what keeps a loaded user
// editable at all.
const userEditShape = {
user_id: z.string().nullish(),
user_email: z.string().nullish(),
user_alias: z.string().nullish(),
user_role: z.string().nullish(),
models: z.array(z.string()),
budget_duration: z.string().nullish(),
metadata: z.string().nullish(),
mcp_servers_and_groups: MCP_SELECTION_SHAPE.optional(),
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
};
const budgetSchema = (unlimitedBudget: boolean) =>
z.object({
...userEditShape,
max_budget: z
.union([z.string(), z.number()])
.nullish()
.refine(
(value) => unlimitedBudget || (value !== "" && value !== null && value !== undefined),
"Please enter a budget or select Unlimited Budget",
),
});
type UserEditFormValues = z.infer<ReturnType<typeof budgetSchema>>;
const buildMcpFieldValues = (objectPermission: ObjectPermission | null | undefined) => ({
mcp_servers_and_groups: {
servers: objectPermission?.mcp_servers ?? [],
@ -33,6 +76,53 @@ const buildMcpFieldValues = (objectPermission: ObjectPermission | null | undefin
mcp_tool_permissions: objectPermission?.mcp_tool_permissions ?? {},
});
// antd only reported the fields that were actually mounted, so the identity and
// MCP keys have to stay out of the store entirely when their controls are not
// rendered, or the request grows keys the previous form never sent.
const toFormValues = (
userData: any,
objectPermission: ObjectPermission | null | undefined,
isBulkEdit: boolean,
canEditMcpPermissions: boolean,
): UserEditFormValues => {
const maxBudget = userData.user_info?.max_budget;
const isUnlimited = maxBudget === null || maxBudget === undefined;
return {
...(isBulkEdit ? {} : { user_id: userData.user_id, user_email: userData.user_info?.user_email }),
user_alias: userData.user_info?.user_alias,
user_role: userData.user_info?.user_role,
models: userData.user_info?.models || [],
max_budget: isUnlimited ? "" : maxBudget,
budget_duration: userData.user_info?.budget_duration,
metadata: userData.user_info?.metadata ? JSON.stringify(userData.user_info.metadata, null, 2) : undefined,
...(canEditMcpPermissions ? buildMcpFieldValues(objectPermission) : {}),
};
};
type ParsedMetadata = { ok: true; value: unknown } | { ok: false };
const parseMetadata = (metadata: string | null | undefined): ParsedMetadata => {
if (!metadata) {
return { ok: true, value: metadata };
}
try {
return { ok: true, value: JSON.parse(metadata) };
} catch (error) {
console.error("Error parsing metadata JSON:", error);
return { ok: false };
}
};
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
export function UserEditView({
userData,
onCancel,
@ -46,214 +136,199 @@ export function UserEditView({
isBulkEdit = false,
objectPermission,
}: UserEditViewProps) {
const [form] = Form.useForm();
const [unlimitedBudget, setUnlimitedBudget] = useState(false);
const canEditMcpPermissions = !isBulkEdit && all_admin_roles.includes(userRole || "");
const [unlimitedBudget, setUnlimitedBudget] = useState(false);
const schema = useMemo(() => budgetSchema(unlimitedBudget), [unlimitedBudget]);
const form = useZodForm(schema, {
defaultValues: toFormValues(userData, objectPermission, isBulkEdit, canEditMcpPermissions),
});
// Set initial form values
React.useEffect(() => {
const maxBudget = userData.user_info?.max_budget;
const isUnlimited = maxBudget === null || maxBudget === undefined;
setUnlimitedBudget(isUnlimited);
setUnlimitedBudget(maxBudget === null || maxBudget === undefined);
form.reset(toFormValues(userData, objectPermission, isBulkEdit, canEditMcpPermissions));
}, [userData, objectPermission, canEditMcpPermissions, isBulkEdit, form]);
form.setFieldsValue({
user_id: userData.user_id,
user_email: userData.user_info?.user_email,
user_alias: userData.user_info?.user_alias,
user_role: userData.user_info?.user_role,
models: userData.user_info?.models || [],
max_budget: isUnlimited ? "" : maxBudget,
budget_duration: userData.user_info?.budget_duration,
metadata: userData.user_info?.metadata ? JSON.stringify(userData.user_info.metadata, null, 2) : undefined,
...(canEditMcpPermissions ? buildMcpFieldValues(objectPermission) : {}),
});
}, [userData, objectPermission, canEditMcpPermissions, form]);
const handleUnlimitedBudgetChange = (e: any) => {
const checked = e.target.checked;
const handleUnlimitedBudgetChange = (checked: boolean) => {
setUnlimitedBudget(checked);
if (checked) {
form.setFieldsValue({ max_budget: "" });
form.setValue("max_budget", "");
}
};
const handleSubmit = (values: any) => {
// Convert metadata back to an object if it exists and is a string
if (values.metadata && typeof values.metadata === "string") {
try {
values.metadata = JSON.parse(values.metadata);
} catch (error) {
console.error("Error parsing metadata JSON:", error);
return;
}
const handleSubmit = (values: UserEditFormValues) => {
const metadata = parseMetadata(values.metadata);
if (!metadata.ok) {
return;
}
if (unlimitedBudget || values.max_budget === "" || values.max_budget === undefined) {
values.max_budget = null;
}
onSubmit(values);
onSubmit({
...values,
...("metadata" in values ? { metadata: metadata.value } : {}),
max_budget:
unlimitedBudget || values.max_budget === "" || values.max_budget === undefined ? null : values.max_budget,
});
};
const modelOptions = [
{ label: "All Proxy Models", value: "all-proxy-models" },
{ label: "No Default Models", value: "no-default-models" },
...userModels.map((model) => ({ label: getModelDisplayName(model), value: model })),
];
const roleOptions = Object.entries(possibleUIRoles ?? {}).map(([role, { ui_label, description }]) => ({
value: role,
label: ui_label,
description,
}));
return (
<Form form={form} onFinish={handleSubmit} layout="vertical">
{!isBulkEdit && (
<Form.Item label="User ID" name="user_id">
<TextInput disabled />
</Form.Item>
)}
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
{!isBulkEdit && (
<FormField control={form.control} name="user_id" label="User ID">
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} disabled />}
</FormField>
)}
{!isBulkEdit && (
<Form.Item label="Email" name="user_email">
<TextInput />
</Form.Item>
)}
{!isBulkEdit && (
<FormField control={form.control} name="user_email" label="Email">
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} />}
</FormField>
)}
<Form.Item label="User Alias" name="user_alias">
<TextInput />
</Form.Item>
<FormField control={form.control} name="user_alias" label="User Alias">
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} />}
</FormField>
<Form.Item
label={
<span>
Global Proxy Role{" "}
<Tooltip title="This is the role that the user will globally on the proxy. This role is independent of any team/org specific roles.">
<InfoCircleOutlined />
</Tooltip>
</span>
}
name="user_role"
>
<Select>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<div className="flex">
{ui_label}{" "}
<p className="ml-2" style={{ color: "gray", fontSize: "12px" }}>
{description}
</p>
</div>
</SelectItem>
))}
</Select>
</Form.Item>
<Form.Item
label={
<span>
Personal Models{" "}
<Tooltip title="Select which models this user can access outside of team-scope. Choose 'All Proxy Models' to grant access to all models available on the proxy.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="models"
>
<Select
mode="multiple"
placeholder="Select models"
style={{ width: "100%" }}
disabled={!all_admin_roles.includes(userRole || "")}
>
<Select.Option key="all-proxy-models" value="all-proxy-models">
All Proxy Models
</Select.Option>
<Select.Option key="no-default-models" value="no-default-models">
No Default Models
</Select.Option>
{userModels.map((model) => (
<Select.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span>Max Budget (USD)</span>
<Checkbox checked={unlimitedBudget} onChange={handleUnlimitedBudgetChange}>
Unlimited Budget
</Checkbox>
</div>
}
name="max_budget"
rules={[
{
validator: (_, value) => {
if (!unlimitedBudget && (value === "" || value === null || value === undefined)) {
return Promise.reject(new Error("Please enter a budget or select Unlimited Budget"));
}
return Promise.resolve();
},
},
]}
>
<NumericalInput step={0.01} precision={2} style={{ width: "100%" }} disabled={unlimitedBudget} />
</Form.Item>
<Form.Item label="Reset Budget" name="budget_duration">
<BudgetDurationDropdown />
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Textarea rows={4} placeholder="Enter metadata as JSON" />
</Form.Item>
{canEditMcpPermissions && (
<>
<Form.Item
label={
<span>
MCP Servers / Access Groups{" "}
<Tooltip title="Caps which MCP servers, access groups, and tools this user may reach. Every key the user holds is limited to this set.">
<InfoCircleOutlined />
</Tooltip>
</span>
}
name="mcp_servers_and_groups"
>
<MCPServerSelector
onChange={(val) => form.setFieldValue("mcp_servers_and_groups", val)}
value={form.getFieldValue("mcp_servers_and_groups")}
accessToken={accessToken || ""}
placeholder="Select MCP servers or access groups (optional)"
/>
</Form.Item>
<Form.Item name="mcp_tool_permissions" initialValue={{}} hidden>
<Input type="hidden" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups ||
prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
}
>
{() => (
<div className="mb-6">
<MCPToolPermissions
accessToken={accessToken || ""}
selectedServers={form.getFieldValue("mcp_servers_and_groups")?.servers || []}
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
/>
</div>
<FormField
control={form.control}
name="user_role"
label={labelWithHint(
"Global Proxy Role",
"This is the role that the user will globally on the proxy. This role is independent of any team/org specific roles.",
)}
</Form.Item>
</>
)}
>
{({ id, value, onChange }) => (
<Select
items={roleOptions}
value={value === undefined || value === "" ? null : value}
onValueChange={(selected: string | null) => onChange(selected ?? undefined)}
>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span>{option.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{option.description}</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
<div className="flex justify-end space-x-2">
<Button variant="secondary" type="button" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</Form>
<FormField
control={form.control}
name="models"
label={labelWithHint(
"Personal Models",
"Select which models this user can access outside of team-scope. Choose 'All Proxy Models' to grant access to all models available on the proxy.",
)}
>
{({ value, onChange }) => (
<MultiSelect
options={modelOptions}
value={value}
onValueChange={onChange}
placeholder="Select models"
disabled={!all_admin_roles.includes(userRole || "")}
/>
)}
</FormField>
<FormField
control={form.control}
name="max_budget"
label={
<>
Max Budget (USD)
<span className="ml-3 inline-flex items-center gap-2 font-normal">
<Checkbox
aria-label="Unlimited Budget"
checked={unlimitedBudget}
onCheckedChange={handleUnlimitedBudgetChange}
/>
Unlimited Budget
</span>
</>
}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
step={0.01}
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Enter a numerical value"
disabled={unlimitedBudget}
/>
)}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
{({ id, value, onChange }) => <BudgetDurationDropdown id={id} value={value} onChange={onChange} />}
</FormField>
<FormField control={form.control} name="metadata" label="Metadata">
{({ ref, value, ...control }) => (
<Textarea {...control} ref={ref} value={value ?? ""} rows={4} placeholder="Enter metadata as JSON" />
)}
</FormField>
{canEditMcpPermissions && (
<>
<FormField
control={form.control}
name="mcp_servers_and_groups"
label={labelWithHint(
"MCP Servers / Access Groups",
"Caps which MCP servers, access groups, and tools this user may reach. Every key the user holds is limited to this set.",
)}
>
{({ value, onChange }) => (
<MCPServerSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select MCP servers or access groups (optional)"
/>
)}
</FormField>
<MCPToolPermissions
accessToken={accessToken || ""}
selectedServers={form.watch("mcp_servers_and_groups")?.servers || []}
toolPermissions={form.watch("mcp_tool_permissions") || {}}
onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)}
/>
</>
)}
</FieldGroup>
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary" type="button" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</form>
</TooltipProvider>
);
}

View file

@ -524,4 +524,222 @@ describe("CreateUserButton", () => {
expect(within(dialog).getByRole("checkbox", { name: /send invitation email/i })).toBeChecked();
});
});
describe("submit payload parity", () => {
const ROLES = {
proxy_user: { ui_label: "User", description: "" },
proxy_admin: { ui_label: "Admin", description: "" },
};
const openStandaloneModal = async (user: ReturnType<typeof userEvent.setup>) => {
expect(await screen.findByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /\+ invite user/i }));
return screen.getByRole("dialog", { name: /invite user/i });
};
const submittedPayload = () => mockUserCreateCall.mock.calls[0][2];
it("should send exactly seven keys from the standalone modal, with the untouched ones undefined", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u1" } });
mockInvitationCreateCall.mockResolvedValue({ id: "i1", user_id: "u1", has_user_setup_sso: false } as any);
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} />);
const dialog = await openStandaloneModal(user);
await user.type(within(dialog).getByLabelText(/user email/i), "parity@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(Object.keys(submittedPayload()).sort()).toEqual([
"metadata",
"models",
"organization_ids",
"send_invite_email",
"team_id",
"user_email",
"user_role",
]);
expect(submittedPayload()).toStrictEqual({
user_email: "parity@example.com",
user_role: "proxy_user",
team_id: undefined,
organization_ids: undefined,
metadata: undefined,
send_invite_email: true,
models: ["no-default-models"],
});
});
it("should send exactly six keys from the embedded form, with no organization_ids", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u2" } });
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} isEmbedded />);
await user.type(screen.getByLabelText(/user email/i), "embedded-parity@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(Object.keys(submittedPayload()).sort()).toEqual([
"metadata",
"models",
"send_invite_email",
"team_id",
"user_email",
"user_role",
]);
expect(submittedPayload()).toStrictEqual({
user_email: "embedded-parity@example.com",
user_role: "proxy_user",
team_id: undefined,
metadata: undefined,
send_invite_email: true,
models: ["no-default-models"],
});
});
it("should not default models to no-default-models for a proxy admin", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u3" } });
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} isEmbedded />);
await user.type(screen.getByLabelText(/user email/i), "admin-parity@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("Admin"));
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(submittedPayload()).not.toHaveProperty("models");
expect(Object.keys(submittedPayload()).sort()).toEqual([
"metadata",
"send_invite_email",
"team_id",
"user_email",
"user_role",
]);
});
it("should rename organization_ids to organizations and drop the original key", async () => {
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
vi.mocked(useOrganizations).mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
isLoading: false,
} as any);
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u4" } });
mockInvitationCreateCall.mockResolvedValue({ id: "i4", user_id: "u4", has_user_setup_sso: false } as any);
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} />);
const dialog = await openStandaloneModal(user);
await user.type(within(dialog).getByLabelText(/user email/i), "org-parity@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByRole("combobox", { name: /organization/i }));
await user.click(screen.getByText("My Org (org-1)"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(submittedPayload()).not.toHaveProperty("organization_ids");
expect(submittedPayload().organizations).toEqual(["org-1"]);
expect(Object.keys(submittedPayload()).sort()).toEqual([
"metadata",
"models",
"organizations",
"send_invite_email",
"team_id",
"user_email",
"user_role",
]);
});
it("should send metadata as the raw string the user typed", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u5" } });
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} isEmbedded />);
await user.type(screen.getByLabelText(/user email/i), "meta-parity@example.com");
await user.click(screen.getByRole("combobox", { name: /user role/i }));
await user.click(screen.getByText("User"));
await user.type(screen.getByLabelText(/metadata/i), '{{"a":1}');
await user.click(screen.getByRole("button", { name: /create user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(submittedPayload().metadata).toBe('{"a":1}');
});
it("should leave models out entirely for a proxy admin created from the standalone modal", async () => {
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u6" } });
mockInvitationCreateCall.mockResolvedValue({ id: "i6", user_id: "u6", has_user_setup_sso: false } as any);
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} />);
const dialog = await openStandaloneModal(user);
await user.type(within(dialog).getByLabelText(/user email/i), "standalone-admin@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("Admin"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(Object.keys(submittedPayload()).sort()).toEqual([
"metadata",
"organization_ids",
"send_invite_email",
"team_id",
"user_email",
"user_role",
]);
});
it("should discard models picked in the personal key section once it is collapsed again", async () => {
// antd paints its Select placeholder with pointer-events: none, so the
// default check would reject the very click a real user makes on it.
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockUserCreateCall.mockResolvedValue({ data: { u: "u7" } });
mockInvitationCreateCall.mockResolvedValue({ id: "i7", user_id: "u7", has_user_setup_sso: false } as any);
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} />);
const dialog = await openStandaloneModal(user);
await user.type(within(dialog).getByLabelText(/user email/i), "collapsed@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
await user.click(within(dialog).getByText("Personal Key Creation"));
// antd Select exposes no accessible name here, the migrated combobox does,
// so the same test has to reach the control either way.
const modelsSelect =
within(dialog).queryByRole("combobox", { name: /select models/i }) ??
(await within(dialog).findByText("Select models"));
await user.click(modelsSelect);
await user.click(await screen.findByText("All Proxy Models"));
await user.click(within(dialog).getByText("Personal Key Creation"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
expect(mockUserCreateCall).toHaveBeenCalled();
});
expect(submittedPayload().models).toEqual(["no-default-models"]);
});
});
});

View file

@ -1,11 +1,19 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Accordion, AccordionBody, AccordionHeader, SelectItem, TextInput } from "@tremor/react";
import { Alert, Checkbox, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd";
import { UserPlus } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Alert, Modal, Typography } from "antd";
import { ChevronRight, CircleHelp, UserPlus } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import TeamDropdown from "./common_components/team_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import { toast } from "@/lib/toast";
@ -17,8 +25,7 @@ import {
userCreateCall,
} from "./networking";
import OnboardingModal, { InvitationLink } from "./onboarding_link";
const { Option } = Select;
const { Text, Link } = Typography;
const { Link } = Typography;
// Helper function to generate UUID compatible across all environments
const generateUUID = (): string => {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
@ -48,6 +55,101 @@ interface UISettings {
SSO_ENABLED: boolean;
}
interface CreateUserFormValues {
user_email?: string;
user_role: string;
team_id?: string;
organization_ids?: string[];
metadata?: string;
send_invite_email: boolean;
models?: string[];
}
const EMBEDDED_DEFAULTS: CreateUserFormValues = {
user_email: undefined,
user_role: "internal_user_viewer",
team_id: undefined,
metadata: undefined,
send_invite_email: true,
};
const STANDALONE_DEFAULTS: CreateUserFormValues = {
user_email: undefined,
user_role: "internal_user_viewer",
team_id: undefined,
organization_ids: undefined,
metadata: undefined,
send_invite_email: true,
};
// antd reported only mounted fields, so a collapsed Personal Key Creation
// section left `models` out of the request entirely even after the admin had
// picked some and closed it again.
const withoutCollapsedModels = (values: CreateUserFormValues, isPersonalKeyOpen: boolean): CreateUserFormValues => {
if (isPersonalKeyOpen) {
return values;
}
const { models, ...rest } = values;
return rest;
};
const buildCreatePayload = (values: CreateUserFormValues): CreateUserFormValues & { organizations?: string[] } => {
const withModels =
(!values.models || values.models.length === 0) && values.user_role !== "proxy_admin"
? { ...values, models: ["no-default-models"] }
: values;
if (!withModels.organization_ids) {
return withModels;
}
const { organization_ids, ...rest } = withModels;
return { ...rest, organizations: organization_ids };
};
const SSO_INVITE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
const buildSsoInvitationLink = (userId: string, createdBy: string): InvitationLink => {
const now = new Date();
return {
id: generateUUID(),
user_id: userId,
is_accepted: false,
accepted_at: null,
expires_at: new Date(now.getTime() + SSO_INVITE_LIFETIME_MS),
created_at: now,
created_by: createdBy,
updated_at: now,
updated_by: createdBy,
has_user_setup_sso: true,
};
};
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
const EmailInvitationsNotice: React.FC = () => (
<Alert
message="Email invitations"
description={
<>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
</>
}
type="info"
showIcon
className="mb-4"
/>
);
export const CreateUserButton: React.FC<CreateuserProps> = ({
userID,
accessToken,
@ -57,14 +159,20 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
}) => {
const queryClient = useQueryClient();
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
const [form] = Form.useForm();
const defaultValues = isEmbedded ? EMBEDDED_DEFAULTS : STANDALONE_DEFAULTS;
const form = useForm<CreateUserFormValues>({ defaultValues });
const [isModalVisible, setIsModalVisible] = useState(false);
const [apiuser, setApiuser] = useState<boolean>(false);
const [userModels, setUserModels] = useState<string[]>([]);
const [isPersonalKeyOpen, setIsPersonalKeyOpen] = useState(false);
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const { data: organizations = [] } = useOrganizations();
const organizationOptions = organizations.map((org) => ({
label: `${org.organization_alias} (${org.organization_id})`,
value: org.organization_id ?? "",
}));
useEffect(() => {
const fetchData = async () => {
@ -88,45 +196,27 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
fetchData();
}, []);
const handleOk = () => {
setIsModalVisible(false);
form.resetFields();
};
const handleCancel = () => {
setIsModalVisible(false);
setApiuser(false);
form.resetFields();
form.reset(defaultValues);
};
const handleCreate = async (formValues: {
user_id: string;
models?: string[];
user_role: string;
organization_ids?: string[];
organizations?: string[];
send_invite_email?: boolean;
}) => {
const handleCreate = async (formValues: CreateUserFormValues) => {
try {
toast.info("Making API Call");
if (!isEmbedded) {
setIsModalVisible(true);
}
if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") {
formValues.models = ["no-default-models"];
}
if (formValues.organization_ids) {
formValues.organizations = formValues.organization_ids;
delete formValues.organization_ids;
}
const response = await userCreateCall(accessToken, null, formValues);
const payload = buildCreatePayload(withoutCollapsedModels(formValues, isPersonalKeyOpen));
const response = await userCreateCall(accessToken, null, payload);
await queryClient.invalidateQueries({ queryKey: ["userList"] });
setApiuser(true);
const user_id = response.data?.user_id || response.user_id;
if (onUserCreated && isEmbedded) {
onUserCreated(user_id);
form.resetFields();
form.reset(defaultValues);
return;
}
@ -137,26 +227,12 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
setIsInvitationLinkModalVisible(true);
});
} else {
// create an InvitationLink Object for this user for the SSO flow
// for SSO the invite link is the proxy base url since the User just needs to login
const invitationLink: InvitationLink = {
id: generateUUID(), // Generate a unique ID
user_id: user_id,
is_accepted: false,
accepted_at: null,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Set expiry to 7 days from now
created_at: new Date(),
created_by: userID, // Assuming userID is the current user creating the invitation
updated_at: new Date(),
updated_by: userID,
has_user_setup_sso: true,
};
setInvitationLinkData(invitationLink);
setInvitationLinkData(buildSsoInvitationLink(user_id, userID));
setIsInvitationLinkModalVisible(true);
}
toast.success("API user Created");
form.resetFields();
form.reset(defaultValues);
localStorage.removeItem("userData" + userID);
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user";
@ -165,66 +241,87 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
}
};
const roleOptions = Object.entries(possibleUIRoles ?? {}).map(([role, { ui_label, description }]) => ({
value: role,
label: ui_label,
description,
}));
const userEmailField = (
<FormField control={form.control} name="user_email" label="User Email">
{({ ref, value, ...control }) => <Input {...control} ref={ref} value={value ?? ""} />}
</FormField>
);
const teamField = (
<FormField
control={form.control}
name="team_id"
label="Team"
description="If selected, user will be added as a 'user' role to the team."
>
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
</FormField>
);
const metadataField = (
<FormField control={form.control} name="metadata" label="Metadata">
{({ ref, value, ...control }) => (
<Textarea {...control} ref={ref} value={value ?? ""} rows={4} placeholder="Enter metadata as JSON" />
)}
</FormField>
);
const sendInviteEmailField = (
<FormField control={form.control} name="send_invite_email" label="Send invitation email">
{({ id, value, onChange, onBlur }) => (
<Checkbox id={id} checked={value} onCheckedChange={onChange} onBlur={onBlur} />
)}
</FormField>
);
const roleField = (label: React.ReactNode) => (
<FormField control={form.control} name="user_role" label={label}>
{({ id, value, onChange }) => (
<Select
items={roleOptions}
value={value === undefined || value === "" ? null : value}
onValueChange={(selected: string | null) => onChange(selected ?? undefined)}
>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span>{option.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{option.description}</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
);
// Modify the return statement to handle embedded mode
if (isEmbedded) {
return (
<Form
form={form}
onFinish={handleCreate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
initialValues={{ user_role: "internal_user_viewer", send_invite_email: true }}
>
<Alert
message="Email invitations"
description={
<>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
configured.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
</>
}
type="info"
showIcon
className="mb-4"
/>
<Form.Item label="User Email" name="user_email">
<TextInput placeholder="" />
</Form.Item>
<Form.Item label="User Role" name="user_role">
<Select2>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<div className="flex">
{ui_label}{" "}
<Text className="ml-2" style={{ color: "gray", fontSize: "12px" }}>
{description}
</Text>
</div>
</SelectItem>
))}
</Select2>
</Form.Item>
<Form.Item label="Team" name="team_id">
<TeamDropdown />
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
</Form.Item>
<Form.Item label="Send invitation email" name="send_invite_email" valuePropName="checked">
<Checkbox />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="submit">Create User</Button>
</div>
</Form>
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleCreate)}>
<EmailInvitationsNotice />
<FieldGroup>
{userEmailField}
{roleField("User Role")}
{teamField}
{metadataField}
{sendInviteEmailField}
</FieldGroup>
<div className="mt-4 text-right">
<Button type="submit">Create User</Button>
</div>
</form>
</TooltipProvider>
);
}
@ -239,134 +336,104 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
open={isModalVisible}
width={800}
footer={null}
onOk={handleOk}
onOk={() => setIsModalVisible(false)}
onCancel={handleCancel}
>
<Space direction="vertical" size="middle">
<Text className="mb-1">Create a User who can own keys</Text>
<Alert
message="Email invitations"
description={
<>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
configured.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
</>
}
type="info"
showIcon
className="mb-4"
/>
</Space>
<Form
form={form}
onFinish={handleCreate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
initialValues={{ user_role: "internal_user_viewer", send_invite_email: true }}
>
<Form.Item label="User Email" name="user_email">
<Input />
</Form.Item>
<Form.Item
label={
<span>
Global Proxy Role{" "}
<Tooltip title="This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings">
<InfoCircleOutlined />
</Tooltip>
</span>
}
name="user_role"
>
<Select2>
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<Text>{ui_label}</Text>
<Text type="secondary">
{" - "}
{description}
</Text>
</SelectItem>
))}
</Select2>
</Form.Item>
<div className="flex flex-col gap-3">
<p className="mb-1 text-sm text-foreground">Create a User who can own keys</p>
<EmailInvitationsNotice />
</div>
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleCreate)}>
<FieldGroup>
{userEmailField}
{roleField(
labelWithHint(
"Global Proxy Role",
"This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings",
),
)}
{teamField}
<Form.Item
label="Team"
className="gap-2"
name="team_id"
help="If selected, user will be added as a 'user' role to the team."
>
<TeamDropdown />
</Form.Item>
<Form.Item
label="Organization"
name="organization_ids"
help="The user will be added to the selected organization(s)."
>
<Select mode="multiple" placeholder="Select Organization" style={{ width: "100%" }}>
{organizations.map((org) => (
<Option key={org.organization_id} value={org.organization_id}>
{org.organization_alias} ({org.organization_id})
</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
</Form.Item>
<Form.Item label="Send invitation email" name="send_invite_email" valuePropName="checked">
<Checkbox />
</Form.Item>
<Accordion>
<AccordionHeader>
<Text strong>Personal Key Creation</Text>
</AccordionHeader>
<AccordionBody>
<Form.Item
className="gap-2"
label={
<span>
Models{" "}
<Tooltip title="Models user has access to, outside of team scope.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="models"
help="Models user has access to, outside of team scope."
<FormField
control={form.control}
name="organization_ids"
label="Organization"
description="The user will be added to the selected organization(s)."
>
<Select2 mode="multiple" placeholder="Select models" style={{ width: "100%" }}>
<Select2.Option key="all-proxy-models" value="all-proxy-models">
All Proxy Models
</Select2.Option>
<Select2.Option key="no-default-models" value="no-default-models">
No Default Models
</Select2.Option>
{userModels.map((model) => (
<Select2.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select2.Option>
))}
</Select2>
</Form.Item>
</AccordionBody>
</Accordion>
{({ id, value, onChange }) => (
<Select
multiple
items={organizationOptions}
value={value ?? []}
onValueChange={(selected: string[]) => onChange(selected.length === 0 ? undefined : selected)}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder="Select Organization">
{(selected: string[]) =>
selected.length === 0
? "Select Organization"
: organizationOptions
.filter((option) => selected.includes(option.value))
.map((option) => option.label)
.join(", ")
}
</SelectValue>
</SelectTrigger>
<SelectContent>
{organizationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="submit">
<UserPlus />
Invite User
</Button>
</div>
</Form>
{metadataField}
{sendInviteEmailField}
<Collapsible open={isPersonalKeyOpen} onOpenChange={setIsPersonalKeyOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-semibold text-foreground">
<ChevronRight
className={`size-4 transition-transform ${isPersonalKeyOpen ? "rotate-90" : ""}`}
aria-hidden
/>
Personal Key Creation
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<FormField
control={form.control}
name="models"
label={labelWithHint("Models", "Models user has access to, outside of team scope.")}
description="Models user has access to, outside of team scope."
>
{({ value, onChange }) => (
<MultiSelect
options={[
{ label: "All Proxy Models", value: "all-proxy-models" },
{ label: "No Default Models", value: "no-default-models" },
...userModels.map((model) => ({ label: getModelDisplayName(model), value: model })),
]}
value={value ?? []}
onValueChange={onChange}
placeholder="Select models"
/>
)}
</FormField>
</CollapsibleContent>
</Collapsible>
</FieldGroup>
<div className="mt-4 text-right">
<Button type="submit">
<UserPlus />
Invite User
</Button>
</div>
</form>
</TooltipProvider>
</Modal>
{apiuser && (
<OnboardingModal

View file

@ -644,4 +644,84 @@ describe("LoggingSettings", () => {
});
expect(clearedFieldNames()).toContain("maximum_spend_logs_cleanup_run_budget");
});
describe("numeric coercion parity", () => {
it("should round a fractional batch size to a whole number, in the input and in the payload", async () => {
const user = userEvent.setup();
mockMutate.mockImplementation((_params, options) => {
options?.onSuccess?.();
});
renderWithProviders(<LoggingSettings />);
const batchSize = screen.getByPlaceholderText("e.g., 1000");
await user.type(batchSize, "2000.7");
await user.click(screen.getByRole("button", { name: "Save Settings" }));
await waitFor(() => {
expect(mockMutate).toHaveBeenCalled();
});
expect(batchSize).toHaveDisplayValue("2001");
expect(mockMutate.mock.calls[0][0]).toEqual({
store_prompts_in_spend_logs: false,
maximum_spend_logs_cleanup_batch_size: 2001,
});
expect(typeof mockMutate.mock.calls[0][0].maximum_spend_logs_cleanup_batch_size).toBe("number");
});
it("should raise a below-minimum batch size to one rather than sending it", async () => {
const user = userEvent.setup();
mockMutate.mockImplementation((_params, options) => {
options?.onSuccess?.();
});
renderWithProviders(<LoggingSettings />);
const batchSize = screen.getByPlaceholderText("e.g., 1000");
await user.type(batchSize, "0");
await user.click(screen.getByRole("button", { name: "Save Settings" }));
await waitFor(() => {
expect(mockMutate).toHaveBeenCalled();
});
expect(batchSize).toHaveDisplayValue("1");
expect(mockMutate.mock.calls[0][0].maximum_spend_logs_cleanup_batch_size).toBe(1);
});
it("should send a trimmable duration exactly as typed, without coercion", async () => {
const user = userEvent.setup();
mockMutate.mockImplementation((_params, options) => {
options?.onSuccess?.();
});
renderWithProviders(<LoggingSettings />);
await user.type(screen.getByPlaceholderText("e.g., 7d, 30d"), "30d");
await user.click(screen.getByRole("button", { name: "Save Settings" }));
await waitFor(() => {
expect(mockMutate).toHaveBeenCalled();
});
expect(mockMutate.mock.calls[0][0]).toEqual({
store_prompts_in_spend_logs: false,
maximum_spend_logs_retention_period: "30d",
});
});
it("should keep a whitespace-only duration out of the payload", async () => {
const user = userEvent.setup();
mockMutate.mockImplementation((_params, options) => {
options?.onSuccess?.();
});
renderWithProviders(<LoggingSettings />);
await user.type(screen.getByPlaceholderText("e.g., 7d, 30d"), " ");
await user.click(screen.getByRole("button", { name: "Save Settings" }));
await waitFor(() => {
expect(mockMutate).toHaveBeenCalled();
});
expect(mockMutate.mock.calls[0][0]).toEqual({ store_prompts_in_spend_logs: false });
});
});
});

View file

@ -12,9 +12,19 @@ import {
} from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
import { toast } from "@/lib/toast";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { ClockCircleOutlined } from "@ant-design/icons";
import { Button, Card, Form, Input, InputNumber, Skeleton, Space, Switch, Typography } from "antd";
import { Card, Skeleton, Space, Typography } from "antd";
import { CircleHelp } from "lucide-react";
import React, { useCallback, useMemo } from "react";
import { useForm } from "react-hook-form";
const STORE_PROMPTS_FIELD_NAME = "store_prompts_in_spend_logs";
@ -68,39 +78,51 @@ const OPTIONAL_FIELDS: readonly OptionalField[] = [
},
];
const MINIMUM_COUNT = 1;
interface LoggingSettingsFormValues {
store_prompts_in_spend_logs: boolean;
maximum_spend_logs_retention_period?: string | null;
maximum_spend_logs_cleanup_batch_size?: number | null;
maximum_spend_logs_cleanup_max_batches?: number | null;
maximum_spend_logs_cleanup_run_budget?: string | null;
maximum_spend_logs_cleanup_batch_timeout?: string | null;
maximum_spend_logs_retention_period: string;
maximum_spend_logs_cleanup_batch_size: string;
maximum_spend_logs_cleanup_max_batches: string;
maximum_spend_logs_cleanup_run_budget: string;
maximum_spend_logs_cleanup_batch_timeout: string;
}
const hasDuration = (value: string | null | undefined): value is string =>
typeof value === "string" && value.trim() !== "";
const duration = (value: string): string | undefined => (value.trim() === "" ? undefined : value);
const hasCount = (value: number | null | undefined): value is number =>
typeof value === "number" && Number.isFinite(value);
// antd InputNumber applied `min` and `precision` to the committed value rather
// than only to the display, and it did so on blur, so the raw keystrokes have
// to survive until then or "12.34" commits as 124 instead of 12.
const count = (raw: string): number | undefined => {
const parsed = Number(raw);
if (raw.trim() === "" || !Number.isFinite(parsed)) {
return undefined;
}
return Math.max(MINIMUM_COUNT, Math.round(parsed));
};
const buildUpdateParams = (formValues: LoggingSettingsFormValues): StoreRequestInSpendLogsParams => ({
store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs,
...(hasDuration(formValues.maximum_spend_logs_retention_period) && {
maximum_spend_logs_retention_period: formValues.maximum_spend_logs_retention_period,
}),
...(hasCount(formValues.maximum_spend_logs_cleanup_batch_size) && {
maximum_spend_logs_cleanup_batch_size: formValues.maximum_spend_logs_cleanup_batch_size,
}),
...(hasCount(formValues.maximum_spend_logs_cleanup_max_batches) && {
maximum_spend_logs_cleanup_max_batches: formValues.maximum_spend_logs_cleanup_max_batches,
}),
...(hasDuration(formValues.maximum_spend_logs_cleanup_run_budget) && {
maximum_spend_logs_cleanup_run_budget: formValues.maximum_spend_logs_cleanup_run_budget,
}),
...(hasDuration(formValues.maximum_spend_logs_cleanup_batch_timeout) && {
maximum_spend_logs_cleanup_batch_timeout: formValues.maximum_spend_logs_cleanup_batch_timeout,
}),
});
const countDisplay = (raw: string): string => {
const normalized = count(raw);
return normalized === undefined ? "" : String(normalized);
};
const buildUpdateParams = (formValues: LoggingSettingsFormValues): StoreRequestInSpendLogsParams => {
const retentionPeriod = duration(formValues.maximum_spend_logs_retention_period);
const batchSize = count(formValues.maximum_spend_logs_cleanup_batch_size);
const maxBatches = count(formValues.maximum_spend_logs_cleanup_max_batches);
const runBudget = duration(formValues.maximum_spend_logs_cleanup_run_budget);
const batchTimeout = duration(formValues.maximum_spend_logs_cleanup_batch_timeout);
return {
store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs,
...(retentionPeriod !== undefined && { maximum_spend_logs_retention_period: retentionPeriod }),
...(batchSize !== undefined && { maximum_spend_logs_cleanup_batch_size: batchSize }),
...(maxBatches !== undefined && { maximum_spend_logs_cleanup_max_batches: maxBatches }),
...(runBudget !== undefined && { maximum_spend_logs_cleanup_run_budget: runBudget }),
...(batchTimeout !== undefined && { maximum_spend_logs_cleanup_batch_timeout: batchTimeout }),
};
};
// A blank field only needs clearing when something is actually stored for it.
// Asking the proxy to clear a field it has no value for is a 400 whenever no
@ -113,8 +135,100 @@ const omittedFieldNames = (
): readonly GeneralSettingsFieldName[] =>
OPTIONAL_FIELDS.map((field) => field.name).filter((name) => !(name in updateParams) && isStored(name));
const labelWithHint = (label: string, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</>
);
interface LoggingSettingsFormProps {
initialValues: LoggingSettingsFormValues;
describeField: (name: string, fallback: string) => string;
isSaving: boolean;
onSubmit: (formValues: LoggingSettingsFormValues) => void;
}
const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
initialValues,
describeField,
isSaving,
onSubmit,
}) => {
const form = useForm<LoggingSettingsFormValues>({ defaultValues: initialValues });
return (
<TooltipProvider>
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<FormField
control={form.control}
name={STORE_PROMPTS_FIELD_NAME}
label={labelWithHint(
"Store Prompts in Spend Logs",
describeField(
STORE_PROMPTS_FIELD_NAME,
"When enabled, prompts will be stored in spend logs for tracking and analysis purposes.",
),
)}
>
{({ id, value, onChange, onBlur }) => (
<Switch id={id} checked={Boolean(value)} onCheckedChange={onChange} onBlur={onBlur} className="w-fit" />
)}
</FormField>
{OPTIONAL_FIELDS.map((field) => (
<FormField
key={field.name}
control={form.control}
name={field.name}
label={labelWithHint(field.label, describeField(field.name, field.fallbackTooltip))}
>
{({ ref, onChange, onBlur, ...control }) =>
field.kind === "duration" ? (
<InputGroup>
<InputGroupInput
{...control}
ref={ref}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
placeholder={field.placeholder}
/>
<InputGroupAddon>
<ClockCircleOutlined />
</InputGroupAddon>
</InputGroup>
) : (
<Input
{...control}
ref={ref}
type="number"
onChange={(event) => onChange(event.target.value)}
onBlur={(event) => {
onChange(countDisplay(event.target.value));
onBlur();
}}
placeholder={field.placeholder}
/>
)
}
</FormField>
))}
</FieldGroup>
<Button type="submit" className="mt-6" disabled={isSaving}>
{isSaving && <UiLoadingSpinner role="img" aria-label="loading" className="size-4" />}
{isSaving ? "Saving..." : "Save Settings"}
</Button>
</form>
</TooltipProvider>
);
};
const LoggingSettings: React.FC = () => {
const [form] = Form.useForm<LoggingSettingsFormValues>();
const { mutate, isPending } = useStoreRequestInSpendLogs();
const { mutate: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField();
const { data: proxyConfigData, isLoading: isLoadingConfig } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
@ -132,12 +246,19 @@ const LoggingSettings: React.FC = () => {
return value !== null && value !== undefined;
};
const initialValues = useMemo(() => {
return {
store_prompts_in_spend_logs: storedValue(STORE_PROMPTS_FIELD_NAME) ?? false,
...Object.fromEntries(OPTIONAL_FIELDS.map((field) => [field.name, storedValue(field.name)])),
};
}, [storedValue]);
const initialValues = useMemo(
() =>
({
store_prompts_in_spend_logs: storedValue(STORE_PROMPTS_FIELD_NAME) ?? false,
...Object.fromEntries(
OPTIONAL_FIELDS.map((field) => {
const stored = storedValue(field.name);
return [field.name, stored === null || stored === undefined ? "" : String(stored)];
}),
),
}) as LoggingSettingsFormValues,
[storedValue],
);
// Resolves to the field name when clearing it failed, or null when it worked.
const clearStoredField = (fieldName: GeneralSettingsFieldName) =>
@ -205,40 +326,12 @@ const LoggingSettings: React.FC = () => {
{isLoadingConfig ? (
<Skeleton active paragraph={{ rows: 4 }} />
) : (
<Form form={form} layout="vertical" onFinish={handleFormSubmit} initialValues={initialValues}>
<Form.Item
label="Store Prompts in Spend Logs"
name={STORE_PROMPTS_FIELD_NAME}
tooltip={describeField(
STORE_PROMPTS_FIELD_NAME,
"When enabled, prompts will be stored in spend logs for tracking and analysis purposes.",
)}
valuePropName="checked"
>
<Switch />
</Form.Item>
{OPTIONAL_FIELDS.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
tooltip={describeField(field.name, field.fallbackTooltip)}
>
{field.kind === "duration" ? (
<Input placeholder={field.placeholder} prefix={<ClockCircleOutlined />} />
) : (
<InputNumber min={1} precision={0} placeholder={field.placeholder} style={{ width: "100%" }} />
)}
</Form.Item>
))}
<Form.Item>
<Button type="primary" htmlType="submit" loading={isPending || isDeletingField}>
{isPending || isDeletingField ? "Saving..." : "Save Settings"}
</Button>
</Form.Item>
</Form>
<LoggingSettingsForm
initialValues={initialValues}
describeField={describeField}
isSaving={isPending || isDeletingField}
onSubmit={handleFormSubmit}
/>
)}
</Space>
</Card>