mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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.
This commit is contained in:
parent
2ee15a6efb
commit
b134c61300
9 changed files with 1290 additions and 293 deletions
|
|
@ -2800,7 +2800,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/team/LoggingSettings.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> = {}): 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(<RegenerateKeyModal selectedToken={token} visible onClose={vi.fn()} onKeyUpdate={onKeyUpdate} />);
|
||||
return { onKeyUpdate };
|
||||
};
|
||||
|
||||
const regenerate = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: /Regenerate/ }));
|
||||
};
|
||||
|
||||
const submittedPayload = (): Record<string, unknown> =>
|
||||
mockRegenerateKeyCall.mock.calls[0][2] as Record<string, unknown>;
|
||||
|
||||
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<KeyResponse>),
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<RegenerateKeyFormValues, RegenerateKeyFormValues> => {
|
||||
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}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
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<string | null>(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 ? (
|
||||
<Flex vertical gap="middle">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Alert type="warning" showIcon message="Save it now, you will not see it again" />
|
||||
|
||||
<Flex vertical gap={2}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Key Alias
|
||||
</Text>
|
||||
<Text>{selectedToken?.key_alias || "No alias set"}</Text>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">Key Alias</span>
|
||||
<span className="text-sm text-foreground">{selectedToken?.key_alias || "No alias set"}</span>
|
||||
</div>
|
||||
|
||||
<Flex vertical gap={6}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Virtual Key
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: 6,
|
||||
padding: "14px 16px",
|
||||
fontFamily: "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace",
|
||||
fontSize: 16,
|
||||
wordBreak: "break-all",
|
||||
color: "#262626",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Virtual Key</span>
|
||||
<div className="rounded-md border border-border bg-muted px-4 py-3.5 font-mono text-base break-all text-foreground">
|
||||
{regeneratedKey}
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 4 }}>
|
||||
<Form.Item name="key_alias" label="Key Alias">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate className="mt-1">
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="key_alias" label="Key Alias">
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} disabled />}
|
||||
</FormField>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="max_budget" label="Max Budget (USD)">
|
||||
<InputNumber step={0.01} precision={2} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="tpm_limit" label="TPM Limit">
|
||||
<InputNumber style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="rpm_limit" label="RPM Limit">
|
||||
<InputNumber style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="duration"
|
||||
label="Expire Key"
|
||||
rules={durationRules}
|
||||
extra={
|
||||
<Flex vertical gap={2}>
|
||||
<Text type={keyIsExpired ? "danger" : "secondary"} style={{ fontSize: 12 }}>
|
||||
Current expiry: {selectedToken?.expires ? formatExpiresUtc(selectedToken.expires) : "Never"}
|
||||
{keyIsExpired && " (expired)"}
|
||||
</Text>
|
||||
{newExpiryTime && (
|
||||
<Text type="success" style={{ fontSize: 12 }}>
|
||||
New expiry: {newExpiryTime}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Input placeholder="e.g. 30s, 30h, 30d" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="grace_period"
|
||||
label="Grace Period"
|
||||
tooltip="Keep the old key valid for this duration after rotation. Both keys work during this period for seamless cutover. Empty = immediate revoke."
|
||||
extra={
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Recommended: 24h to 72h for production keys
|
||||
</Text>
|
||||
}
|
||||
rules={[DURATION_RULE]}
|
||||
>
|
||||
<Input placeholder="e.g. 24h, 2d" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<FormField control={form.control} name="tpm_limit" label="TPM Limit">
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="rpm_limit" label="RPM Limit">
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="duration"
|
||||
label="Expire Key"
|
||||
description={
|
||||
<span className="flex flex-col gap-0.5 text-xs">
|
||||
<span className={keyIsExpired ? "text-destructive" : "text-muted-foreground"}>
|
||||
Current expiry: {selectedToken?.expires ? formatExpiresUtc(selectedToken.expires) : "Never"}
|
||||
{keyIsExpired && " (expired)"}
|
||||
</span>
|
||||
{newExpiryTime && (
|
||||
<span className="text-green-600 dark:text-green-400">New expiry: {newExpiryTime}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. 30s, 30h, 30d" />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grace_period"
|
||||
label={labelWithHint(
|
||||
"Grace Period",
|
||||
"Keep the old key valid for this duration after rotation. Both keys work during this period for seamless cutover. Empty = immediate revoke.",
|
||||
)}
|
||||
description={<span className="text-xs">Recommended: 24h to 72h for production keys</span>}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. 24h, 2d" />}
|
||||
</FormField>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildRegenerateKeyPayload, roundToPrecision, type RegenerateKeyFormValues } from "./regenerateKeyPayload";
|
||||
|
||||
const values = (overrides: Partial<RegenerateKeyFormValues> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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<string, unknown>;
|
||||
|
||||
const renderEdit = (config: object, initialData: Member) =>
|
||||
renderWithProviders(
|
||||
<EditMembership
|
||||
visible
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
mode="edit"
|
||||
config={config as never}
|
||||
initialData={initialData as never}
|
||||
/>,
|
||||
);
|
||||
|
||||
const save = () => fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
const submitted = (): Record<string, unknown> => onSubmit.mock.calls[0][0] as Record<string, unknown>;
|
||||
|
||||
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(
|
||||
<EditMembership
|
||||
visible
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
mode="add"
|
||||
config={{ ...orgMemberConfig, title: "Add Member", defaultRole: "user" } as never}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<EditMembership
|
||||
visible
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
mode="add"
|
||||
config={{ ...orgMemberConfig, title: "Add Member" } as never}
|
||||
/>,
|
||||
);
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<T extends BaseMember> {
|
||||
|
|
@ -40,6 +39,23 @@ interface MemberModalProps<T extends BaseMember> {
|
|||
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<MemberFormValues, MemberFormValues> => {
|
||||
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 = <T extends BaseMember>({
|
||||
visible,
|
||||
onCancel,
|
||||
|
|
@ -48,56 +64,21 @@ const MemberModal = <T extends BaseMember>({
|
|||
mode,
|
||||
config,
|
||||
}: MemberModalProps<T>) => {
|
||||
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 = <T extends BaseMember>({
|
|||
}
|
||||
};
|
||||
|
||||
// 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 <TextInput placeholder={field.placeholder} />;
|
||||
case "numerical":
|
||||
return (
|
||||
<NumericalInput
|
||||
step={field.step || 1}
|
||||
min={field.min || 0}
|
||||
style={{ width: "100%" }}
|
||||
placeholder={field.placeholder || "Enter a numerical value"}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Select>
|
||||
{field.options?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
case "multi-select":
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={field.placeholder || "Select options"}
|
||||
options={field.options}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
case "budget-duration":
|
||||
return <BudgetDurationDropdown />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const orderedRoleOptions =
|
||||
mode === "edit" && initialData
|
||||
? [
|
||||
...config.roleOptions.filter((option) => option.value === initialData.role),
|
||||
...config.roleOptions.filter((option) => option.value !== initialData.role),
|
||||
]
|
||||
: config.roleOptions;
|
||||
|
||||
const renderField = (field: MemberAdditionalField, name: string) => (
|
||||
<FormField key={name} control={form.control} name={name} label={field.label}>
|
||||
{({ ref, id, value, onChange, ...rest }) => {
|
||||
switch (field.type) {
|
||||
case "input":
|
||||
return (
|
||||
<Input
|
||||
{...rest}
|
||||
id={id}
|
||||
ref={ref}
|
||||
placeholder={field.placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
case "numerical":
|
||||
return (
|
||||
<NumericalInput
|
||||
{...rest}
|
||||
id={id}
|
||||
step={field.step || 1}
|
||||
min={field.min || 0}
|
||||
style={{ width: "100%" }}
|
||||
placeholder={field.placeholder || "Enter a numerical value"}
|
||||
value={value ?? ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
items={Object.fromEntries((field.options ?? []).map((option) => [option.value, option.label]))}
|
||||
value={typeof value === "string" && value !== "" ? value : null}
|
||||
onValueChange={(selected: string | null) => onChange(selected ?? undefined)}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
case "multi-select":
|
||||
return (
|
||||
<MultiSelect
|
||||
options={field.options ?? []}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onValueChange={onChange}
|
||||
placeholder={field.placeholder || "Select options"}
|
||||
/>
|
||||
);
|
||||
case "budget-duration":
|
||||
return (
|
||||
<BudgetDurationDropdown
|
||||
id={id}
|
||||
value={typeof value === "string" ? value : null}
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
</FormField>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -166,78 +175,83 @@ const MemberModal = <T extends BaseMember>({
|
|||
footer={null}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<Form form={form} onFinish={handleSubmit} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
{config.showEmail && (
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="user_email"
|
||||
className="mb-4"
|
||||
rules={[{ type: "email", message: "Please enter a valid email!" }]}
|
||||
>
|
||||
<TextInput placeholder="user@example.com" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{config.showEmail && config.showUserId && (
|
||||
<div className="text-center mb-4">
|
||||
<Text>OR</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.showUserId && (
|
||||
<Form.Item label="User ID" name="user_id" className="mb-4">
|
||||
<TextInput placeholder="user_123" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Role</span>
|
||||
{mode === "edit" && initialData && (
|
||||
<span className="text-gray-500 text-sm">(Current: {getRoleLabel(initialData.role)})</span>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<FieldGroup>
|
||||
{config.showEmail && (
|
||||
<FormField control={form.control} name="user_email" label="Email">
|
||||
{({ ref, value, onChange, ...rest }) => (
|
||||
<Input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
placeholder="user@example.com"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
name="role"
|
||||
className="mb-4"
|
||||
rules={[{ required: true, message: "Please select a role!" }]}
|
||||
>
|
||||
<Select>
|
||||
{mode === "edit" && initialData
|
||||
? [
|
||||
// Current role first
|
||||
...config.roleOptions.filter((option) => option.value === initialData.role),
|
||||
// Then all other roles
|
||||
...config.roleOptions.filter((option) => option.value !== initialData.role),
|
||||
].map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))
|
||||
: config.roleOptions.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{config.additionalFields?.map((field) => (
|
||||
<Form.Item key={field.name} label={field.label} name={field.name} className="mb-4" rules={field.rules}>
|
||||
{renderField(field)}
|
||||
</Form.Item>
|
||||
))}
|
||||
{config.showEmail && config.showUserId && <div className="text-center text-sm text-muted-foreground">OR</div>}
|
||||
|
||||
<div className="text-right mt-6">
|
||||
<AntButton onClick={onCancel} className="mr-2" disabled={isSubmitting}>
|
||||
{config.showUserId && (
|
||||
<FormField control={form.control} name="user_id" label="User ID">
|
||||
{({ ref, value, onChange, ...rest }) => (
|
||||
<Input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
placeholder="user_123"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
<span>Role</span>
|
||||
{mode === "edit" && initialData && (
|
||||
<span className="text-sm text-muted-foreground">(Current: {getRoleLabel(initialData.role)})</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<Select
|
||||
items={Object.fromEntries(orderedRoleOptions.map((option) => [option.value, option.label]))}
|
||||
value={typeof value === "string" && value !== "" ? value : null}
|
||||
onValueChange={(selected: string | null) => onChange(selected ?? undefined)}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedRoleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{config.additionalFields?.map((field) => renderField(field, field.name))}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 text-right">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting} className="mr-2">
|
||||
Cancel
|
||||
</AntButton>
|
||||
<AntButton type="default" htmlType="submit" loading={isSubmitting}>
|
||||
</Button>
|
||||
<Button type="submit" variant="outline" disabled={isSubmitting}>
|
||||
{isSubmitting && <UiLoadingSpinner className="size-4" />}
|
||||
{mode === "add" ? (isSubmitting ? "Adding..." : "Add Member") : isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</AntButton>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
71
ui/litellm-dashboard/src/components/team/memberFormValues.ts
Normal file
71
ui/litellm-dashboard/src/components/team/memberFormValues.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export type MemberFieldValue = string | number | null | undefined | string[];
|
||||
|
||||
export type MemberFormValues = Record<string, MemberFieldValue>;
|
||||
|
||||
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<MemberAdditionalField>;
|
||||
}
|
||||
|
||||
const NULLABLE_NUMERIC_FIELDS: ReadonlySet<string> = 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];
|
||||
}),
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue