diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 4aace020dda..64615c50cc1 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2800,7 +2800,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/components/team/LoggingSettings.tsx": { diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx new file mode 100644 index 00000000000..0a1ddd73313 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import { RegenerateKeyModal } from "./RegenerateKeyModal"; +import { KeyResponse } from "../key_team_helpers/key_list"; + +const mockRegenerateKeyCall = vi.fn(); +vi.mock("../networking", () => ({ + regenerateKeyCall: (...args: unknown[]) => mockRegenerateKeyCall(...args), +})); + +const makeToken = (overrides: Partial = {}): KeyResponse => + ({ + token: "token-hash-123", + token_id: "token-id-123", + key_name: "sk-test-key", + key_alias: "my-test-key", + max_budget: 100, + tpm_limit: 5000, + rpm_limit: 500, + duration: "30d", + expires: "2026-12-31T00:00:00Z", + ...overrides, + }) as KeyResponse; + +const renderModal = (token: KeyResponse | null = makeToken(), onKeyUpdate = vi.fn()) => { + renderWithProviders(); + return { onKeyUpdate }; +}; + +const regenerate = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: /Regenerate/ })); +}; + +const submittedPayload = (): Record => + mockRegenerateKeyCall.mock.calls[0][2] as Record; + +describe("RegenerateKeyModal submit payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRegenerateKeyCall.mockResolvedValue({ key: "sk-new-regenerated-key", token: "new-token-hash" }); + }); + + it("sends the seeded key fields untouched, with grace_period blank", async () => { + const user = userEvent.setup(); + renderModal(); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload()).toStrictEqual({ + key_alias: "my-test-key", + max_budget: 100, + tpm_limit: 5000, + rpm_limit: 500, + duration: "30d", + grace_period: "", + }); + }); + + it("keeps the wire body's key order and drops nothing through JSON serialisation", async () => { + const user = userEvent.setup(); + renderModal(); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(JSON.stringify(submittedPayload())).toBe( + '{"key_alias":"my-test-key","max_budget":100,"tpm_limit":5000,"rpm_limit":500,"duration":"30d","grace_period":""}', + ); + }); + + it("rounds max_budget to two decimals and sends the other numbers unrounded", async () => { + const user = userEvent.setup(); + renderModal(); + + const budget = screen.getByLabelText("Max Budget (USD)"); + await user.clear(budget); + await user.type(budget, "42.567"); + const tpm = screen.getByLabelText("TPM Limit"); + await user.clear(tpm); + await user.type(tpm, "1234"); + const rpm = screen.getByLabelText("RPM Limit"); + await user.clear(rpm); + await user.type(rpm, "56"); + const duration = screen.getByPlaceholderText("e.g. 30s, 30h, 30d"); + await user.clear(duration); + await user.type(duration, "7d"); + await user.type(screen.getByPlaceholderText("e.g. 24h, 2d"), "24h"); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload()).toStrictEqual({ + key_alias: "my-test-key", + max_budget: 42.57, + tpm_limit: 1234, + rpm_limit: 56, + duration: "7d", + grace_period: "24h", + }); + }); + + it.each([ + ["1.005", 1.01], + ["2.675", 2.68], + ["1.0049999", 1], + ["0.125", 0.13], + ["7", 7], + ])("rounds a typed max_budget of %s to %s", async (typed, expected) => { + const user = userEvent.setup(); + renderModal(); + + const budget = screen.getByLabelText("Max Budget (USD)"); + await user.clear(budget); + await user.type(budget, typed); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload().max_budget).toBe(expected); + }); + + it.each([ + ["TPM Limit", "tpm_limit"], + ["RPM Limit", "rpm_limit"], + ])("still submits a fractional %s rather than letting a step constraint block it", async (label, key) => { + const user = userEvent.setup(); + renderModal(); + + const input = screen.getByLabelText(label); + await user.clear(input); + await user.type(input, "12.7"); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload()[key]).toBe(12.7); + }); + + it("sends null for cleared numbers and an empty string for a cleared duration", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.clear(screen.getByLabelText("Max Budget (USD)")); + await user.clear(screen.getByLabelText("TPM Limit")); + await user.clear(screen.getByLabelText("RPM Limit")); + await user.clear(screen.getByPlaceholderText("e.g. 30s, 30h, 30d")); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload()).toStrictEqual({ + key_alias: "my-test-key", + max_budget: null, + tpm_limit: null, + rpm_limit: null, + duration: "", + grace_period: "", + }); + }); + + it("keeps unset key fields undefined so JSON omits them", async () => { + const user = userEvent.setup(); + renderModal( + makeToken({ + key_alias: undefined, + max_budget: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + duration: undefined, + }), + ); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload()).toStrictEqual({ + key_alias: undefined, + max_budget: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + duration: "", + grace_period: "", + }); + expect(JSON.stringify(submittedPayload())).toBe('{"duration":"","grace_period":""}'); + }); + + it("regenerates a key whose fields the API returned as null", async () => { + const user = userEvent.setup(); + renderModal( + makeToken({ + key_alias: null, + max_budget: null, + tpm_limit: null, + rpm_limit: null, + duration: null, + } as unknown as Partial), + ); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload()).toStrictEqual({ + key_alias: null, + max_budget: null, + tpm_limit: null, + rpm_limit: null, + duration: "", + grace_period: "", + }); + }); + + it("targets the key by its token hash", async () => { + const user = userEvent.setup(); + renderModal(); + + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(mockRegenerateKeyCall.mock.calls[0].slice(0, 2)).toStrictEqual(["123", "token-hash-123"]); + }); + + it("blocks submission and sends nothing when the duration is unparseable", async () => { + const user = userEvent.setup(); + renderModal(); + + const duration = screen.getByPlaceholderText("e.g. 30s, 30h, 30d"); + await user.clear(duration); + await user.type(duration, "bogus"); + + await regenerate(user); + + expect(await screen.findByText("Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo")).toBeInTheDocument(); + expect(mockRegenerateKeyCall).not.toHaveBeenCalled(); + }); + + it("blocks submission when an expired key is regenerated without a new duration", async () => { + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-06-06T12:00:00Z")); + const user = userEvent.setup(); + renderModal(makeToken({ expires: "2026-06-01T12:00:00Z", duration: "" })); + + await regenerate(user); + + expect(await screen.findByText("Expiration is required for expired keys")).toBeInTheDocument(); + expect(mockRegenerateKeyCall).not.toHaveBeenCalled(); + }); + + it("rejects an unparseable grace period", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByPlaceholderText("e.g. 24h, 2d"), "soon"); + + await regenerate(user); + + expect(await screen.findByText("Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo")).toBeInTheDocument(); + expect(mockRegenerateKeyCall).not.toHaveBeenCalled(); + }); + + it("reveals the new key only after the call resolves and hands the parent the submitted limits", async () => { + const user = userEvent.setup(); + const { onKeyUpdate } = renderModal(); + + expect(screen.queryByText("sk-new-regenerated-key")).not.toBeInTheDocument(); + + await regenerate(user); + + expect(await screen.findByText("sk-new-regenerated-key")).toBeInTheDocument(); + expect(onKeyUpdate).toHaveBeenCalledWith({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + key_name: "sk-new-regenerated-key", + max_budget: 100, + tpm_limit: 5000, + rpm_limit: 500, + expires: "2026-12-31T00:00:00Z", + }); + }); + + it("never persists the regenerated key to web storage", async () => { + const localSetItem = vi.spyOn(Storage.prototype, "setItem"); + const user = userEvent.setup(); + renderModal(); + + await regenerate(user); + + expect(await screen.findByText("sk-new-regenerated-key")).toBeInTheDocument(); + expect(localSetItem.mock.calls.flat()).not.toContain("sk-new-regenerated-key"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index 3902f3f736b..2edd502eee3 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -1,20 +1,60 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { CheckOutlined, CopyOutlined, SyncOutlined } from "@ant-design/icons"; -import { Alert, Button, Col, Flex, Form, Input, InputNumber, Modal, Row, Space, Typography } from "antd"; -import { useEffect, useState } from "react"; +import { Alert, Button, Modal, Space } from "antd"; +import { CircleHelp } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { useWatch } from "react-hook-form"; import { CopyToClipboard } from "react-copy-to-clipboard"; +import { z } from "zod/v4"; import { KeyResponse } from "../key_team_helpers/key_list"; import { toast } from "@/lib/toast"; import { regenerateKeyCall } from "../networking"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useZodForm } from "@/lib/forms/useZodForm"; import { calculateExpiryPreviewFromDuration, formatExpiresUtc, isKeyExpired } from "@/utils/keyExpiryUtils"; +import { buildRegenerateKeyPayload, type RegenerateKeyFormValues } from "./regenerateKeyPayload"; -const { Text } = Typography; +const DURATION_PATTERN = /^(\d+(s|m|h|d|w|mo))?$/; +const DURATION_MESSAGE = "Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo"; +const EXPIRED_DURATION_MESSAGE = "Expiration is required for expired keys"; -const DURATION_RULE = { - pattern: /^(\d+(s|m|h|d|w|mo))?$/, - message: "Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo", +const EMPTY_VALUES: RegenerateKeyFormValues = { + key_alias: undefined, + max_budget: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + duration: "", + grace_period: "", }; +const buildSchema = (keyIsExpired: boolean): z.ZodType => { + const shape = { + key_alias: z.string().nullish(), + max_budget: z.number().nullish(), + tpm_limit: z.number().nullish(), + rpm_limit: z.number().nullish(), + duration: keyIsExpired + ? z.string().min(1, EXPIRED_DURATION_MESSAGE).regex(DURATION_PATTERN, DURATION_MESSAGE) + : z.string().regex(DURATION_PATTERN, DURATION_MESSAGE), + grace_period: z.string().regex(DURATION_PATTERN, DURATION_MESSAGE), + }; + + return z.object(shape); +}; + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + interface RegenerateKeyModalProps { selectedToken: KeyResponse | null; visible: boolean; @@ -24,42 +64,36 @@ interface RegenerateKeyModalProps { export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdate }: RegenerateKeyModalProps) { const { accessToken } = useAuthorized(); - const [form] = Form.useForm(); const [regeneratedKey, setRegeneratedKey] = useState(null); const [isRegenerating, setIsRegenerating] = useState(false); const [copied, setCopied] = useState(false); const keyIsExpired = isKeyExpired(selectedToken?.expires); - const durationValue = Form.useWatch("duration", form); - - // Expired keys must get a new duration, otherwise regeneration produces a key - // that inherits the old (past) expiry and is immediately unusable. - const durationRules = keyIsExpired - ? [{ required: true, message: "Expiration is required for expired keys" }, DURATION_RULE] - : [DURATION_RULE]; + const schema = useMemo(() => buildSchema(keyIsExpired), [keyIsExpired]); + const form = useZodForm(schema, { defaultValues: EMPTY_VALUES }); + const durationValue = useWatch({ control: form.control, name: "duration" }); useEffect(() => { if (visible && selectedToken && accessToken) { - form.setFieldsValue({ + const seededValues: RegenerateKeyFormValues = { key_alias: selectedToken.key_alias, max_budget: selectedToken.max_budget, tpm_limit: selectedToken.tpm_limit, rpm_limit: selectedToken.rpm_limit, duration: selectedToken.duration || "", grace_period: "", - }); + }; + form.reset(seededValues); } }, [visible, selectedToken, form, accessToken]); const newExpiryTime = durationValue ? calculateExpiryPreviewFromDuration(durationValue) : null; - const handleRegenerateKey = async () => { + const submitRegenerateKey = async (values: RegenerateKeyFormValues) => { if (!selectedToken || !accessToken) return; - setIsRegenerating(true); + const formValues = buildRegenerateKeyPayload(values); try { - const formValues = await form.validateFields(); - const response = await regenerateKeyCall(accessToken, selectedToken.token || selectedToken.token_id, formValues); setRegeneratedKey(response.key); toast.success("Virtual Key regenerated successfully"); @@ -88,21 +122,23 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat setIsRegenerating(false); } catch (error) { setIsRegenerating(false); // Reset regenerating state on error - // Ant Design form validation rejections surface inline under the field; - // don't also raise a backend-style toast for them. - if (error && typeof error === "object" && "errorFields" in error) { - return; - } console.error("Error regenerating key:", error); toast.fromError(error); } }; + const handleRegenerateKey = () => { + if (!selectedToken || !accessToken) return; + + setIsRegenerating(true); + void form.handleSubmit(submitRegenerateKey, () => setIsRegenerating(false))(); + }; + const handleClose = () => { setRegeneratedKey(null); setIsRegenerating(false); setCopied(false); - form.resetFields(); + form.reset(EMPTY_VALUES); onClose(); }; @@ -140,100 +176,103 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat } > {regeneratedKey ? ( - +
- - - Key Alias - - {selectedToken?.key_alias || "No alias set"} - +
+ Key Alias + {selectedToken?.key_alias || "No alias set"} +
- - - Virtual Key - -
+
+ Virtual Key +
{regeneratedKey}
- - +
+
) : ( -
- - - + + event.preventDefault()} noValidate className="mt-1"> + + + {({ ref, value, ...field }) => } + - - - - - - - - - - - - - - - - - +
+ + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + - - - - - Current expiry: {selectedToken?.expires ? formatExpiresUtc(selectedToken.expires) : "Never"} - {keyIsExpired && " (expired)"} - - {newExpiryTime && ( - - New expiry: {newExpiryTime} - - )} - - } - > - - - - - - Recommended: 24h to 72h for production keys - - } - rules={[DURATION_RULE]} - > - - - - - + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + + + + {({ ref, value, onChange, ...field }) => ( + onChange(event.target.value === "" ? null : event.target.valueAsNumber)} + /> + )} + +
+ +
+ + + Current expiry: {selectedToken?.expires ? formatExpiresUtc(selectedToken.expires) : "Never"} + {keyIsExpired && " (expired)"} + + {newExpiryTime && ( + New expiry: {newExpiryTime} + )} + + } + > + {({ ref, ...field }) => } + + + Recommended: 24h to 72h for production keys} + > + {({ ref, ...field }) => } + +
+
+ +
)} ); diff --git a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts new file mode 100644 index 00000000000..2411c0d0733 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { buildRegenerateKeyPayload, roundToPrecision, type RegenerateKeyFormValues } from "./regenerateKeyPayload"; + +const values = (overrides: Partial = {}): RegenerateKeyFormValues => ({ + key_alias: "my-test-key", + max_budget: 100, + tpm_limit: 5000, + rpm_limit: 500, + duration: "30d", + grace_period: "", + ...overrides, +}); + +describe("roundToPrecision", () => { + it.each([ + [42.567, 42.57], + [1.005, 1.01], + [2.675, 2.68], + [0.125, 0.13], + [1.0049999, 1], + [7, 7], + [1000, 1000], + [0, 0], + [0.004, 0], + [0.005, 0.01], + [1e-7, 0], + ])("rounds %s to %s", (input, expected) => { + expect(roundToPrecision(input, 2)).toBe(expected); + }); + + it("rounds the magnitude of a negative away from zero", () => { + expect(roundToPrecision(-3.456, 2)).toBe(-3.46); + expect(roundToPrecision(-1.005, 2)).toBe(-1.01); + }); + + it("does not reintroduce binary floating point noise", () => { + expect(roundToPrecision(1.1 + 2.2, 2)).toBe(3.3); + expect(String(roundToPrecision(8.165, 2))).toBe("8.17"); + }); + + it("honours a precision other than two", () => { + expect(roundToPrecision(1.2345, 3)).toBe(1.235); + expect(roundToPrecision(1.5, 0)).toBe(2); + }); + + it("returns unroundable values untouched", () => { + expect(roundToPrecision(Number.POSITIVE_INFINITY, 2)).toBe(Number.POSITIVE_INFINITY); + expect(roundToPrecision(Number.NaN, 2)).toBeNaN(); + expect(roundToPrecision(Number.MAX_VALUE, 2)).toBe(Number.MAX_VALUE); + }); +}); + +describe("buildRegenerateKeyPayload", () => { + it("rounds max_budget and leaves every other field alone", () => { + expect(buildRegenerateKeyPayload(values({ max_budget: 42.567, tpm_limit: 12.7, rpm_limit: 9.99 }))).toStrictEqual({ + key_alias: "my-test-key", + max_budget: 42.57, + tpm_limit: 12.7, + rpm_limit: 9.99, + duration: "30d", + grace_period: "", + }); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ])("passes a %s max_budget through without coercing it to a number", (_label, budget) => { + expect(buildRegenerateKeyPayload(values({ max_budget: budget })).max_budget).toBe(budget); + }); + + it("keeps unset fields as undefined so the request body omits them", () => { + const payload = buildRegenerateKeyPayload( + values({ key_alias: undefined, max_budget: undefined, tpm_limit: undefined, rpm_limit: undefined }), + ); + + expect(payload).toStrictEqual({ + key_alias: undefined, + max_budget: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + duration: "30d", + grace_period: "", + }); + expect(JSON.stringify(payload)).toBe('{"duration":"30d","grace_period":""}'); + }); + + it("preserves the field order the proxy request body is serialised in", () => { + expect(Object.keys(buildRegenerateKeyPayload(values({ max_budget: 1.239 })))).toStrictEqual([ + "key_alias", + "max_budget", + "tpm_limit", + "rpm_limit", + "duration", + "grace_period", + ]); + }); + + it("does not mutate the values it is handed", () => { + const original = values({ max_budget: 42.567 }); + + buildRegenerateKeyPayload(original); + + expect(original.max_budget).toBe(42.567); + }); +}); diff --git a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts new file mode 100644 index 00000000000..b20f099d930 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts @@ -0,0 +1,30 @@ +export const MAX_BUDGET_PRECISION = 2; + +export interface RegenerateKeyFormValues { + key_alias?: string | null; + max_budget?: number | null; + tpm_limit?: number | null; + rpm_limit?: number | null; + duration: string; + grace_period: string; +} + +const shiftExponent = (value: number, places: number): number => { + const [mantissa, exponent = "0"] = value.toExponential().split("e"); + return Number(`${mantissa}e${Number(exponent) + places}`); +}; + +export const roundToPrecision = (value: number, precision: number): number => { + const scaled = shiftExponent(Math.abs(value), precision); + if (!Number.isFinite(scaled)) return value; + const rounded = shiftExponent(Math.round(scaled), -precision); + return value < 0 ? -rounded : rounded; +}; + +export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyFormValues => ({ + ...values, + max_budget: + typeof values.max_budget === "number" + ? roundToPrecision(values.max_budget, MAX_BUDGET_PRECISION) + : values.max_budget, +}); diff --git a/ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx b/ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx new file mode 100644 index 00000000000..b1dd3a2efcb --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx @@ -0,0 +1,254 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import EditMembership from "./EditMembership"; + +const onSubmit = vi.fn(); + +const roleOptions = [ + { label: "Admin", value: "admin" }, + { label: "User", value: "user" }, +]; + +const additionalFields = [ + { name: "max_budget_in_team", label: "Team Member Budget (USD)", type: "numerical" as const, step: 0.01, min: 0 }, + { name: "budget_duration", label: "Budget Reset Period", type: "budget-duration" as const }, + { name: "tpm_limit", label: "Team Member TPM Limit", type: "numerical" as const, step: 1, min: 0 }, + { name: "rpm_limit", label: "Team Member RPM Limit", type: "numerical" as const, step: 1, min: 0 }, + { + name: "allowed_models", + label: "Allowed Models", + type: "multi-select" as const, + options: [ + { label: "gpt-4o", value: "gpt-4o" }, + { label: "claude", value: "claude" }, + ], + }, +]; + +const teamMemberConfig = { title: "Edit Member", showEmail: true, showUserId: true, roleOptions, additionalFields }; + +const orgMemberConfig = { title: "Edit Member", showEmail: true, showUserId: true, roleOptions }; + +type Member = Record; + +const renderEdit = (config: object, initialData: Member) => + renderWithProviders( + , + ); + +const save = () => fireEvent.click(screen.getByRole("button", { name: "Save Changes" })); + +const submitted = (): Record => onSubmit.mock.calls[0][0] as Record; + +describe("EditMembership submit payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits only the fields the config renders, never the rest of the member record", async () => { + renderEdit(teamMemberConfig, { + user_id: "u1", + user_email: "a@b.com", + role: "user", + max_budget_in_team: 12.5, + budget_duration: "24h", + tpm_limit: 100, + rpm_limit: 20, + allowed_models: ["gpt-4o"], + spend: 3.21, + team_id: "t1", + created_at: "2026-01-01T00:00:00Z", + }); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted()).toStrictEqual({ + user_email: "a@b.com", + user_id: "u1", + role: "user", + max_budget_in_team: 12.5, + budget_duration: "24h", + tpm_limit: 100, + rpm_limit: 20, + allowed_models: ["gpt-4o"], + }); + }); + + it("omits a field the config hides even when the member record carries it", async () => { + renderEdit({ ...orgMemberConfig, showUserId: false }, { user_id: "u1", user_email: "a@b.com", role: "admin" }); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted()).toStrictEqual({ user_email: "a@b.com", role: "admin" }); + }); + + it.each([ + [ + { user_id: "u1", user_email: null, role: "user" }, + { user_email: null, user_id: "u1", role: "user" }, + ], + [ + { user_id: null, user_email: "a@b.com", role: "user" }, + { user_email: "a@b.com", user_id: null, role: "user" }, + ], + ])("submits a member whose identity the API returned as null", async (initialData, expected) => { + renderEdit(orgMemberConfig, initialData as Member); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted()).toStrictEqual(expected); + }); + + it("collapses falsy budget and limit values to null and a missing model list to an empty array", async () => { + renderEdit(teamMemberConfig, { + user_id: "u1", + user_email: "a@b.com", + role: "user", + max_budget_in_team: 0, + tpm_limit: 0, + rpm_limit: 0, + budget_duration: "", + }); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted()).toStrictEqual({ + user_email: "a@b.com", + user_id: "u1", + role: "user", + max_budget_in_team: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + allowed_models: [], + }); + }); + + it("submits a typed numeric field as the raw string and a cleared one as null", async () => { + renderEdit(teamMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "user", tpm_limit: 7 }); + + fireEvent.change(screen.getByLabelText("Team Member Budget (USD)"), { target: { value: "42.56" } }); + fireEvent.change(screen.getByLabelText("Team Member TPM Limit"), { target: { value: "" } }); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted().max_budget_in_team).toBe("42.56"); + expect(submitted().tpm_limit).toBeNull(); + }); + + it("trims surrounding whitespace off text fields", async () => { + renderEdit(orgMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "user" }); + + fireEvent.change(screen.getByLabelText("User ID"), { target: { value: " padded-id " } }); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted().user_id).toBe("padded-id"); + }); + + it("keeps a blanked text field as an empty string rather than null", async () => { + renderEdit(orgMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "user" }); + + fireEvent.change(screen.getByLabelText("User ID"), { target: { value: " " } }); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted().user_id).toBe(""); + }); + + it("registers the hidden fields in add mode and sends only the default role", async () => { + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Add Member" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted()).toStrictEqual({ user_email: undefined, user_id: undefined, role: "user" }); + expect(JSON.stringify(submitted())).toBe('{"role":"user"}'); + }); + + it("falls back to the first role option when the config names no default", async () => { + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Add Member" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(submitted().role).toBe("admin"); + }); + + it("blocks submission when the email is not an address", async () => { + renderEdit(orgMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "user" }); + + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "not-an-email" } }); + save(); + + expect(await screen.findByText("Please enter a valid email!")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("blocks submission when no role is selected", async () => { + renderEdit(orgMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "" }); + + save(); + + expect(await screen.findByText("Please select a role!")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it.each([ + ["Team Member Budget (USD)", "42.567", "stepMismatch"], + ["Team Member TPM Limit", "12.7", "stepMismatch"], + ["Team Member Budget (USD)", "-5", "rangeUnderflow"], + ])("blocks submission when %s holds %s", async (label, value, violation) => { + renderEdit(teamMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "user" }); + + const input = screen.getByLabelText(label) as HTMLInputElement; + fireEvent.change(input, { target: { value } }); + + expect(input.validity[violation as "stepMismatch" | "rangeUnderflow"]).toBe(true); + + save(); + + await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()); + }); + + it("clears the fields once the submit handler resolves", async () => { + renderEdit(orgMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "user" }); + + save(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + await waitFor(() => expect(screen.getByLabelText("User ID")).toHaveValue("")); + expect(screen.getByLabelText("Email")).toHaveValue(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/EditMembership.tsx b/ui/litellm-dashboard/src/components/team/EditMembership.tsx index beeabae310f..e89a62778d8 100644 --- a/ui/litellm-dashboard/src/components/team/EditMembership.tsx +++ b/ui/litellm-dashboard/src/components/team/EditMembership.tsx @@ -1,8 +1,24 @@ -import { Text, TextInput } from "@tremor/react"; -import { Button as AntButton, Form, Modal, Select } from "antd"; -import React, { useEffect, useState } from "react"; +import { Modal } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { z } from "zod/v4"; import NumericalInput from "../shared/numerical_input"; import BudgetDurationDropdown from "../common_components/budget_duration_dropdown"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { useZodForm } from "@/lib/forms/useZodForm"; +import { + buildMemberFormData, + buildMemberFormValues, + emptyMemberFormValues, + type MemberAdditionalField, + type MemberFieldsConfig, + type MemberFormValues, +} from "./memberFormValues"; interface BaseMember { user_email?: string; @@ -10,25 +26,8 @@ interface BaseMember { role: string; } -interface ModalConfig { +interface ModalConfig extends MemberFieldsConfig { title: string; - roleOptions: Array<{ - label: string; - value: string; - }>; - defaultRole?: string; - showEmail?: boolean; - showUserId?: boolean; - additionalFields?: Array<{ - name: string; - label: string | React.ReactNode; - type: "input" | "select" | "numerical" | "multi-select" | "budget-duration"; - options?: Array<{ label: string; value: string }>; - rules?: any[]; - step?: number; - min?: number; - placeholder?: string; - }>; } interface MemberModalProps { @@ -40,6 +39,23 @@ interface MemberModalProps { config: ModalConfig; } +const ROLE_REQUIRED_MESSAGE = "Please select a role!"; + +const isEmailish = (value: string): boolean => value === "" || z.email().safeParse(value).success; + +const memberFieldSchema = z.union([z.string(), z.number(), z.null(), z.array(z.string())]).optional(); + +const buildMemberSchema = (config: ModalConfig): z.ZodType => { + const shape = { + user_email: z.string().refine(isEmailish, "Please enter a valid email!").nullish(), + user_id: z.string().nullish(), + role: z.string({ error: ROLE_REQUIRED_MESSAGE }).min(1, ROLE_REQUIRED_MESSAGE), + ...Object.fromEntries((config.additionalFields ?? []).map((field) => [field.name, memberFieldSchema])), + }; + + return z.object(shape); +}; + const MemberModal = ({ visible, onCancel, @@ -48,56 +64,21 @@ const MemberModal = ({ mode, config, }: MemberModalProps) => { - const [form] = Form.useForm(); + const schema = useMemo(() => buildMemberSchema(config), [config]); + const form = useZodForm(schema, { defaultValues: emptyMemberFormValues(config) }); const [isSubmitting, setIsSubmitting] = useState(false); - // Reset form and set initial values when modal becomes visible or initialData changes useEffect(() => { if (visible) { - if (mode === "edit" && initialData) { - // For edit mode, use the initialData values - const formValues = { - ...initialData, - // Ensure role is set correctly for editing - role: initialData.role || config.defaultRole, - // Keep numeric values as numbers for NumericalInput components - max_budget_in_team: (initialData as any).max_budget_in_team || null, - tpm_limit: (initialData as any).tpm_limit || null, - rpm_limit: (initialData as any).rpm_limit || null, - budget_duration: (initialData as any).budget_duration || null, - // Keep array values for multi-select fields - allowed_models: (initialData as any).allowed_models || [], - }; - form.setFieldsValue(formValues); - } else { - // For add mode, reset to defaults - form.resetFields(); - form.setFieldsValue({ - role: config.defaultRole || config.roleOptions[0]?.value, - }); - } + form.reset(buildMemberFormValues(mode, initialData as MemberFormValues | null | undefined, config)); } - }, [visible, initialData, mode, form, config.defaultRole, config.roleOptions]); + }, [visible, initialData, mode, form, config]); - const handleSubmit = async (values: any) => { + const handleSubmit = async (values: MemberFormValues) => { try { setIsSubmitting(true); - // Trim string values and clean up form data - const formData = Object.entries(values).reduce((acc, [key, value]) => { - if (typeof value === "string") { - const trimmedValue = value.trim(); - // For empty strings on optional numeric fields, set to null - if (trimmedValue === "" && (key === "max_budget_in_team" || key === "tpm_limit" || key === "rpm_limit")) { - return { ...acc, [key]: null }; - } - return { ...acc, [key]: trimmedValue }; - } - // For numeric values from NumericalInput, use as-is (already numbers) - return { ...acc, [key]: value }; - }, {}) as T; - - await Promise.resolve(onSubmit(formData)); - form.resetFields(); + await Promise.resolve(onSubmit(buildMemberFormData(values) as unknown as T)); + form.reset(emptyMemberFormValues(config)); } catch (error) { console.error("Form submission error:", error); } finally { @@ -105,58 +86,86 @@ const MemberModal = ({ } }; - // Helper function to get role label from value - const getRoleLabel = (value: string) => { - return config.roleOptions.find((option) => option.value === value)?.label || value; - }; + const getRoleLabel = (value: string) => config.roleOptions.find((option) => option.value === value)?.label || value; - const renderField = (field: { - name: string; - label: string | React.ReactNode; - type: "input" | "select" | "numerical" | "multi-select" | "budget-duration"; - options?: Array<{ label: string; value: string }>; - rules?: any[]; - step?: number; - min?: number; - placeholder?: string; - }) => { - switch (field.type) { - case "input": - return ; - case "numerical": - return ( - - ); - case "select": - return ( - - ); - case "multi-select": - return ( - onChange(event.target.value)} + /> + ); + case "numerical": + return ( + ) => onChange(event.target.value)} + /> + ); + case "select": + return ( + + ); + case "multi-select": + return ( + + ); + case "budget-duration": + return ( + onChange(next)} + /> + ); + default: + return null; + } + }} + + ); return ( ({ footer={null} onCancel={onCancel} > -
- {config.showEmail && ( - - - - )} - - {config.showEmail && config.showUserId && ( -
- OR -
- )} - - {config.showUserId && ( - - - - )} - - - Role - {mode === "edit" && initialData && ( - (Current: {getRoleLabel(initialData.role)}) + + + {config.showEmail && ( + + {({ ref, value, onChange, ...rest }) => ( + onChange(event.target.value)} + /> )} -
- } - name="role" - className="mb-4" - rules={[{ required: true, message: "Please select a role!" }]} - > - - + + )} - {config.additionalFields?.map((field) => ( - - {renderField(field)} - - ))} + {config.showEmail && config.showUserId &&
OR
} -
- + {config.showUserId && ( + + {({ ref, value, onChange, ...rest }) => ( + onChange(event.target.value)} + /> + )} + + )} + + + Role + {mode === "edit" && initialData && ( + (Current: {getRoleLabel(initialData.role)}) + )} + + } + > + {({ id, value, onChange }) => ( + + )} + + + {config.additionalFields?.map((field) => renderField(field, field.name))} + + +
+ +
- + ); }; diff --git a/ui/litellm-dashboard/src/components/team/memberFormValues.test.ts b/ui/litellm-dashboard/src/components/team/memberFormValues.test.ts new file mode 100644 index 00000000000..b03f0f6e09e --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/memberFormValues.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { + buildMemberFormData, + buildMemberFormValues, + emptyMemberFormValues, + memberFieldNames, + type MemberFieldsConfig, +} from "./memberFormValues"; + +const roleOptions = [ + { label: "Admin", value: "admin" }, + { label: "User", value: "user" }, +]; + +const teamConfig: MemberFieldsConfig = { + roleOptions, + showEmail: true, + showUserId: true, + additionalFields: [ + { name: "max_budget_in_team", label: "Budget", type: "numerical" }, + { name: "budget_duration", label: "Reset", type: "budget-duration" }, + { name: "tpm_limit", label: "TPM", type: "numerical" }, + { name: "rpm_limit", label: "RPM", type: "numerical" }, + { name: "allowed_models", label: "Models", type: "multi-select" }, + ], +}; + +const orgConfig: MemberFieldsConfig = { roleOptions, showEmail: true, showUserId: true }; + +describe("memberFieldNames", () => { + it("lists the rendered fields in submit order", () => { + expect(memberFieldNames(teamConfig)).toStrictEqual([ + "user_email", + "user_id", + "role", + "max_budget_in_team", + "budget_duration", + "tpm_limit", + "rpm_limit", + "allowed_models", + ]); + }); + + it.each([ + [{ roleOptions, showEmail: false, showUserId: true }, ["user_id", "role"]], + [{ roleOptions, showEmail: true, showUserId: false }, ["user_email", "role"]], + [{ roleOptions }, ["role"]], + ])("drops the identity fields the config hides", (config, expected) => { + expect(memberFieldNames(config as MemberFieldsConfig)).toStrictEqual(expected); + }); +}); + +describe("buildMemberFormValues", () => { + it("seeds only the rendered fields, never the rest of the member record", () => { + expect( + buildMemberFormValues( + "edit", + { + user_email: "a@b.com", + user_id: "u1", + role: "user", + max_budget_in_team: 12.5, + budget_duration: "24h", + tpm_limit: 100, + rpm_limit: 20, + allowed_models: ["gpt-4o"], + spend: 3.21, + team_id: "t1", + created_at: "2026-01-01T00:00:00Z", + }, + teamConfig, + ), + ).toStrictEqual({ + user_email: "a@b.com", + user_id: "u1", + role: "user", + max_budget_in_team: 12.5, + budget_duration: "24h", + tpm_limit: 100, + rpm_limit: 20, + allowed_models: ["gpt-4o"], + }); + }); + + it("collapses falsy budgets and limits to null and a missing model list to an empty array", () => { + expect( + buildMemberFormValues( + "edit", + { user_email: "a@b.com", user_id: "u1", role: "user", max_budget_in_team: 0, tpm_limit: 0, rpm_limit: 0 }, + teamConfig, + ), + ).toStrictEqual({ + user_email: "a@b.com", + user_id: "u1", + role: "user", + max_budget_in_team: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + allowed_models: [], + }); + }); + + it("falls back to the configured default role when the member has none", () => { + expect(buildMemberFormValues("edit", { user_id: "u1", role: "" }, { ...orgConfig, defaultRole: "user" }).role).toBe( + "user", + ); + }); + + it("seeds add mode with the default role and leaves every other field unset", () => { + expect(buildMemberFormValues("add", null, { ...orgConfig, defaultRole: "user" })).toStrictEqual({ + user_email: undefined, + user_id: undefined, + role: "user", + }); + }); + + it("falls back to the first role option when no default is configured", () => { + expect(buildMemberFormValues("add", null, orgConfig).role).toBe("admin"); + }); + + it("ignores the member record in add mode", () => { + expect(buildMemberFormValues("add", { user_email: "a@b.com", role: "admin" }, orgConfig)).toStrictEqual({ + user_email: undefined, + user_id: undefined, + role: "admin", + }); + }); + + it("treats edit mode without a record as add mode", () => { + expect(buildMemberFormValues("edit", null, { ...orgConfig, defaultRole: "user" }).role).toBe("user"); + }); +}); + +describe("emptyMemberFormValues", () => { + it("unsets every rendered field so a reset clears the form", () => { + expect(emptyMemberFormValues(orgConfig)).toStrictEqual({ + user_email: undefined, + user_id: undefined, + role: undefined, + }); + }); +}); + +describe("buildMemberFormData", () => { + it("trims strings and keeps everything else by reference", () => { + const models = ["gpt-4o"]; + + expect( + buildMemberFormData({ + user_email: " a@b.com ", + user_id: " u1 ", + role: "user", + max_budget_in_team: 12.5, + allowed_models: models, + }), + ).toStrictEqual({ + user_email: "a@b.com", + user_id: "u1", + role: "user", + max_budget_in_team: 12.5, + allowed_models: models, + }); + }); + + it.each(["max_budget_in_team", "tpm_limit", "rpm_limit"])("turns a blank %s into null", (key) => { + expect(buildMemberFormData({ [key]: " " })[key]).toBeNull(); + }); + + it.each(["user_email", "user_id", "budget_duration"])("leaves a blank %s as an empty string", (key) => { + expect(buildMemberFormData({ [key]: " " })[key]).toBe(""); + }); + + it("keeps a typed numeric value as the raw string it was typed as", () => { + expect(buildMemberFormData({ max_budget_in_team: "42.56" }).max_budget_in_team).toBe("42.56"); + }); + + it("passes null and undefined through untouched", () => { + expect(buildMemberFormData({ tpm_limit: null, user_email: undefined })).toStrictEqual({ + tpm_limit: null, + user_email: undefined, + }); + }); + + it("preserves field order", () => { + expect(Object.keys(buildMemberFormData({ user_email: "a@b.com", user_id: "u1", role: "user" }))).toStrictEqual([ + "user_email", + "user_id", + "role", + ]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/memberFormValues.ts b/ui/litellm-dashboard/src/components/team/memberFormValues.ts new file mode 100644 index 00000000000..b39e2a07401 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/memberFormValues.ts @@ -0,0 +1,71 @@ +export type MemberFieldValue = string | number | null | undefined | string[]; + +export type MemberFormValues = Record; + +export type MemberFieldType = "input" | "select" | "numerical" | "multi-select" | "budget-duration"; + +export interface MemberAdditionalField { + name: string; + label: string | React.ReactNode; + type: MemberFieldType; + options?: Array<{ label: string; value: string }>; + step?: number; + min?: number; + placeholder?: string; +} + +export interface MemberFieldsConfig { + roleOptions: Array<{ label: string; value: string }>; + defaultRole?: string; + showEmail?: boolean; + showUserId?: boolean; + additionalFields?: Array; +} + +const NULLABLE_NUMERIC_FIELDS: ReadonlySet = new Set(["max_budget_in_team", "tpm_limit", "rpm_limit"]); + +export const memberFieldNames = (config: MemberFieldsConfig): string[] => [ + ...(config.showEmail ? ["user_email"] : []), + ...(config.showUserId ? ["user_id"] : []), + "role", + ...(config.additionalFields ?? []).map((field) => field.name), +]; + +const pickFieldNames = (config: MemberFieldsConfig, source: MemberFormValues): MemberFormValues => + Object.fromEntries(memberFieldNames(config).map((name) => [name, source[name]])); + +export const buildMemberFormValues = ( + mode: "add" | "edit", + initialData: MemberFormValues | null | undefined, + config: MemberFieldsConfig, +): MemberFormValues => { + if (mode === "edit" && initialData) { + const seeded: MemberFormValues = { + ...initialData, + role: (initialData.role as string) || config.defaultRole, + max_budget_in_team: initialData.max_budget_in_team || null, + tpm_limit: initialData.tpm_limit || null, + rpm_limit: initialData.rpm_limit || null, + budget_duration: initialData.budget_duration || null, + allowed_models: initialData.allowed_models || [], + }; + + return pickFieldNames(config, seeded); + } + + return pickFieldNames(config, { role: config.defaultRole || config.roleOptions[0]?.value }); +}; + +export const emptyMemberFormValues = (config: MemberFieldsConfig): MemberFormValues => pickFieldNames(config, {}); + +export const buildMemberFormData = (values: MemberFormValues): MemberFormValues => + Object.fromEntries( + Object.entries(values).map(([key, value]) => { + if (typeof value !== "string") return [key, value]; + + const trimmed = value.trim(); + if (trimmed === "" && NULLABLE_NUMERIC_FIELDS.has(key)) return [key, null]; + + return [key, trimmed]; + }), + );