mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
9ec0145986
commit
5e2d6addc4
9 changed files with 1727 additions and 821 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue