From b134c61300a5b69961e3518e85d12de214ac5bf0 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 11:42:33 -0700 Subject: [PATCH] refactor(ui): migrate the regenerate key and team member forms to react-hook-form and shadcn (#37300) * refactor(ui): migrate the regenerate key and team member forms to react-hook-form and shadcn Moves RegenerateKeyModal and EditMembership off antd Form onto react-hook-form plus the shadcn field kit, and onto semantic color tokens so both are dark-mode ready. The antd Modal shell and Alert stay as they are. The submitted payload is unchanged on both. Payload construction is extracted into regenerateKeyPayload.ts and memberFormValues.ts and unit tested there, and each component keeps an integration test that was written against the antd original and proven green before any source changed. RegenerateKeyModal keeps antd InputNumber's precision=2 rounding of max_budget. The rounding is string-exact rather than float based, so 1.005 still submits as 1.01 the way antd did. Submission stays on the modal footer button, so Enter still does nothing. EditMembership omits noValidate. Its numeric fields are already native number inputs carrying min and step, so browser constraint validation blocks a bad submit today and continues to. That is pinned by tests. * fix(ui): accept null-valued fields the proxy returns for keys and members The proxy returns null rather than omitting the key for an unset key_alias, user_email or user_id. antd had no schema and forwarded whatever came back, but z.string().optional() accepts undefined and rejects null, so regenerating an alias-less key or editing a member with no email failed validation and silently never submitted. Widen those three fields to nullish() and pin each with a test that seeds null and submits without touching the field. Each test passes against the pre-migration antd component and failed against the migration before this commit, and the payloads it asserts are the ones antd put on the wire. --- ui/litellm-dashboard/eslint-suppressions.json | 2 +- .../RegenerateKeyModal.integration.test.tsx | 291 ++++++++++++++ .../organisms/RegenerateKeyModal.tsx | 263 ++++++------ .../organisms/regenerateKeyPayload.test.ts | 106 +++++ .../organisms/regenerateKeyPayload.ts | 30 ++ .../team/EditMembership.integration.test.tsx | 254 ++++++++++++ .../src/components/team/EditMembership.tsx | 374 +++++++++--------- .../components/team/memberFormValues.test.ts | 192 +++++++++ .../src/components/team/memberFormValues.ts | 71 ++++ 9 files changed, 1290 insertions(+), 293 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts create mode 100644 ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts create mode 100644 ui/litellm-dashboard/src/components/team/EditMembership.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/team/memberFormValues.test.ts create mode 100644 ui/litellm-dashboard/src/components/team/memberFormValues.ts 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]; + }), + );