mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): move the shared key form controls off antd onto shadcn (#37348)
* test(ui): pin the antd submit payloads for the key create and edit forms Characterization only, no source change. Both suites are green against the current antd components, so they can gate the react-hook-form migration that follows without being edited. key_edit_view had no exact-payload assertion, only objectContaining, so nothing caught a form that started sending server-only key fields. The new case asserts the whole object. create_key_button's existing suite runs against a hand-rolled antd fake and stubs out KeyLifecycleSettings and RateLimitTypeFormItem, so neither the real store nor those two controls were covered. The new file drives the real antd form and pins the network payload instead. * refactor(ui): move the shared key form controls off antd onto shadcn KeyLifecycleSettings and RateLimitTypeFormItem each owned an antd Form.Item and took the parent's FormInstance as a prop, so neither could be hosted by anything but an antd form. That is what made the key create and edit forms one inseparable migration unit. Both are now presentational: they take value and onChange and let the parent own the binding, so an antd Form.Item and a react-hook-form FormField can host them equally. The two parents keep their antd forms for now and pass the binding down unchanged, which is why every existing payload assertion still holds. Controls are shadcn Select, Input, Switch and Checkbox on semantic colour tokens, so both are dark-mode ready. The rotation notice keeps its blue hue and gains a dark variant rather than flattening to a neutral. The form prop the two components took was already inert: antd dispatches its own store update before calling the child's onChange, so setFieldValue was writing a value the store had just been given. KeyLifecycleSettings.test.tsx keeps every assertion; only the harness moves the duration binding up into a Form.Item, and one case disables user-event's pointer-events check because Base UI leaves a reopened select popup inert under jsdom, reproduced on a bare shadcn Select with none of this code involved. * test(ui): pin the role-gated key fields and tidy the new assertions Adds the case that proves policies and prompts leave the payload entirely for a role that cannot see them, which a react-hook-form port would otherwise start sending from defaultValues. Green against antd like the rest. Also hoists the two large expected payloads into named constants and drops two unused exports, so the lane adds no new lint-budget pressure. * fix(ui): give the key expiry input and Never Expire checkbox separate labels The expiry label carried htmlFor for the duration input while also wrapping the Never Expire checkbox and its own label, so the two controls shared one ambiguous association. Splitting the row into a plain container with a label per control makes each name resolve to the control it describes. Also drops the prop and test comments added in this branch, which the repository comment policy does not allow. * test(ui): pin the create-form expiry binding to the generate payload create_key_button coalesces a missing or blank duration to null before it calls keyCreateCall, so the key is present in the payload whether or not the control is bound to the form. Every existing case stayed green with the Form.Item removed, which left the binding uncovered. The new case opens Key Lifecycle, types an expiry, and asserts it arrives as that value. Proven red with the Form.Item removed and green with it restored.
This commit is contained in:
parent
76ff0d5351
commit
573aa61084
7 changed files with 601 additions and 245 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from "react";
|
||||
// eslint-disable-next-line no-restricted-imports -- exercising KeyLifecycleSettings requires hosting it in a real antd Form (the component it's built on)
|
||||
import { Form } from "antd";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
|
||||
import KeyLifecycleSettings from "./KeyLifecycleSettings";
|
||||
|
|
@ -22,16 +22,17 @@ const Harness: React.FC<HarnessProps> = ({ isCreateMode = true, onFinish = () =>
|
|||
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
<KeyLifecycleSettings
|
||||
form={form}
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
isCreateMode={isCreateMode}
|
||||
neverExpire={neverExpire}
|
||||
onNeverExpireChange={setNeverExpire}
|
||||
/>
|
||||
<Form.Item name="duration" initialValue="" noStyle>
|
||||
<KeyLifecycleSettings
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
isCreateMode={isCreateMode}
|
||||
neverExpire={neverExpire}
|
||||
onNeverExpireChange={setNeverExpire}
|
||||
/>
|
||||
</Form.Item>
|
||||
<button type="submit">submit</button>
|
||||
<button type="button" onClick={() => form.resetFields()}>
|
||||
reset
|
||||
|
|
@ -58,6 +59,12 @@ describe("KeyLifecycleSettings", () => {
|
|||
expect(getDurationInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the duration input and the Never Expire checkbox their own labels", () => {
|
||||
renderWithProviders(<Harness isCreateMode={false} />);
|
||||
expect(screen.getByLabelText("Expire Key")).toBe(getDurationInput(false));
|
||||
expect(screen.getByRole("checkbox", { name: "Never Expire" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the create-mode placeholder in create mode", () => {
|
||||
renderWithProviders(<Harness isCreateMode={true} />);
|
||||
expect(screen.getByPlaceholderText(CREATE_PLACEHOLDER)).toBeInTheDocument();
|
||||
|
|
@ -194,7 +201,7 @@ describe("KeyLifecycleSettings", () => {
|
|||
});
|
||||
|
||||
it("hides the custom input and propagates the value when switching back to a predefined interval", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
|
|
|
|||
|
|
@ -1,23 +1,50 @@
|
|||
import React, { useState } from "react";
|
||||
import { Select, Tooltip, Divider, Switch, Checkbox, Form } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
const { Option } = Select;
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
const PREDEFINED_INTERVALS = ["7d", "30d", "90d", "180d", "365d"] as const;
|
||||
|
||||
const INTERVAL_LABELS: Record<string, string> = {
|
||||
"7d": "7 days",
|
||||
"30d": "30 days",
|
||||
"90d": "90 days",
|
||||
"180d": "180 days",
|
||||
"365d": "365 days",
|
||||
custom: "Custom interval",
|
||||
};
|
||||
|
||||
interface KeyLifecycleSettingsProps {
|
||||
form: any; // Form instance from parent
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
autoRotationEnabled: boolean;
|
||||
onAutoRotationChange: (enabled: boolean) => void;
|
||||
rotationInterval: string;
|
||||
onRotationIntervalChange: (interval: string) => void;
|
||||
isCreateMode?: boolean; // If true, shows "leave empty to never expire" instead of "-1 to never expire"
|
||||
isCreateMode?: boolean;
|
||||
neverExpire?: boolean;
|
||||
onNeverExpireChange?: (checked: boolean) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const hintIcon = (hint: string): React.ReactNode => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />}
|
||||
aria-label={hint}
|
||||
/>
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
|
||||
form,
|
||||
value,
|
||||
onChange,
|
||||
autoRotationEnabled,
|
||||
onAutoRotationChange,
|
||||
rotationInterval,
|
||||
|
|
@ -25,145 +52,151 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
|
|||
isCreateMode = false,
|
||||
neverExpire = false,
|
||||
onNeverExpireChange,
|
||||
id,
|
||||
}) => {
|
||||
// Predefined intervals
|
||||
const predefinedIntervals = ["7d", "30d", "90d", "180d", "365d"];
|
||||
|
||||
// Check if current interval is custom
|
||||
const isCustomInterval = rotationInterval && !predefinedIntervals.includes(rotationInterval);
|
||||
const isCustomInterval = Boolean(rotationInterval) && !PREDEFINED_INTERVALS.includes(rotationInterval as never);
|
||||
|
||||
const [showCustomInput, setShowCustomInput] = useState(isCustomInterval);
|
||||
const [customInterval, setCustomInterval] = useState(isCustomInterval ? rotationInterval : "");
|
||||
|
||||
const handleIntervalChange = (value: string) => {
|
||||
if (value === "custom") {
|
||||
const durationId = id ?? "key-lifecycle-duration";
|
||||
|
||||
const handleIntervalChange = (next: string) => {
|
||||
if (next === "custom") {
|
||||
setShowCustomInput(true);
|
||||
// Don't change the actual interval yet, wait for custom input
|
||||
} else {
|
||||
setShowCustomInput(false);
|
||||
setCustomInterval("");
|
||||
onRotationIntervalChange(value);
|
||||
return;
|
||||
}
|
||||
setShowCustomInput(false);
|
||||
setCustomInterval("");
|
||||
onRotationIntervalChange(next);
|
||||
};
|
||||
|
||||
const handleCustomIntervalChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomInterval(event.target.value);
|
||||
onRotationIntervalChange(event.target.value);
|
||||
};
|
||||
|
||||
const handleNeverExpireChange = (checked: boolean) => {
|
||||
onNeverExpireChange?.(checked);
|
||||
if (checked) {
|
||||
onChange?.("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomIntervalChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setCustomInterval(value);
|
||||
onRotationIntervalChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Key Expiry Section */}
|
||||
<div className="space-y-4">
|
||||
<span className="text-sm font-medium text-gray-700">Key Expiry Settings</span>
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<span className="text-sm font-medium text-foreground">Key Expiry Settings</span>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
|
||||
<span>Expire Key</span>
|
||||
<Tooltip title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged.">
|
||||
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
|
||||
</Tooltip>
|
||||
{!isCreateMode && onNeverExpireChange && (
|
||||
<Checkbox
|
||||
checked={neverExpire}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
onNeverExpireChange(checked);
|
||||
if (checked) {
|
||||
if (form && typeof form.setFieldValue === "function") {
|
||||
form.setFieldValue("duration", "");
|
||||
} else if (form && typeof form.setFieldsValue === "function") {
|
||||
form.setFieldsValue({ duration: "" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="ml-2 text-sm font-normal text-gray-600"
|
||||
>
|
||||
Never Expire
|
||||
</Checkbox>
|
||||
)}
|
||||
</label>
|
||||
<Form.Item name="duration" noStyle initialValue="">
|
||||
<TextInput
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-1 text-sm font-medium text-foreground">
|
||||
<label htmlFor={durationId}>Expire Key</label>
|
||||
{hintIcon(
|
||||
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged.",
|
||||
)}
|
||||
{!isCreateMode && onNeverExpireChange && (
|
||||
<span className="ml-2 flex items-center gap-2 text-sm font-normal text-muted-foreground">
|
||||
<Checkbox
|
||||
id={`${durationId}-never-expire`}
|
||||
checked={neverExpire}
|
||||
onCheckedChange={handleNeverExpireChange}
|
||||
/>
|
||||
<label htmlFor={`${durationId}-never-expire`} className="cursor-pointer">
|
||||
Never Expire
|
||||
</label>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id={durationId}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d"}
|
||||
className="w-full"
|
||||
disabled={!isCreateMode && neverExpire}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<Separator />
|
||||
|
||||
{/* Auto-Rotation Section */}
|
||||
<div className="space-y-4">
|
||||
<span className="text-sm font-medium text-gray-700">Auto-Rotation Settings</span>
|
||||
<div className="space-y-4">
|
||||
<span className="text-sm font-medium text-foreground">Auto-Rotation Settings</span>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
|
||||
<span>Enable Auto-Rotation</span>
|
||||
<Tooltip title="Key will automatically regenerate at the specified interval for enhanced security.">
|
||||
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Switch
|
||||
checked={autoRotationEnabled}
|
||||
onChange={onAutoRotationChange}
|
||||
size="default"
|
||||
className={autoRotationEnabled ? "" : "bg-gray-400"}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-1 text-sm font-medium text-foreground">
|
||||
<span>Enable Auto-Rotation</span>
|
||||
{hintIcon("Key will automatically regenerate at the specified interval for enhanced security.")}
|
||||
</label>
|
||||
<Switch checked={autoRotationEnabled} onCheckedChange={onAutoRotationChange} />
|
||||
</div>
|
||||
|
||||
{autoRotationEnabled && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-1 text-sm font-medium text-foreground">
|
||||
<span>Rotation Interval</span>
|
||||
{hintIcon(
|
||||
"How often the key should be automatically rotated. Choose the interval that best fits your security requirements.",
|
||||
)}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={showCustomInput ? "custom" : rotationInterval || null}
|
||||
onValueChange={(next: string | null) => next !== null && handleIntervalChange(next)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select interval">
|
||||
{(selected: string | null) =>
|
||||
selected === null ? (
|
||||
"Select interval"
|
||||
) : (
|
||||
<span title={INTERVAL_LABELS[selected] ?? selected}>
|
||||
{INTERVAL_LABELS[selected] ?? selected}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PREDEFINED_INTERVALS.map((interval) => (
|
||||
<SelectItem key={interval} value={interval} title={INTERVAL_LABELS[interval]}>
|
||||
{INTERVAL_LABELS[interval]}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom" title={INTERVAL_LABELS.custom}>
|
||||
{INTERVAL_LABELS.custom}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showCustomInput && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={customInterval}
|
||||
onChange={handleCustomIntervalChange}
|
||||
placeholder="e.g., 1s, 5m, 2h, 14d"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Supported formats: seconds (s), minutes (m), hours (h), days (d)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{autoRotationEnabled && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
|
||||
<span>Rotation Interval</span>
|
||||
<Tooltip title="How often the key should be automatically rotated. Choose the interval that best fits your security requirements.">
|
||||
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={showCustomInput ? "custom" : rotationInterval}
|
||||
onChange={handleIntervalChange}
|
||||
className="w-full"
|
||||
placeholder="Select interval"
|
||||
>
|
||||
<Option value="7d">7 days</Option>
|
||||
<Option value="30d">30 days</Option>
|
||||
<Option value="90d">90 days</Option>
|
||||
<Option value="180d">180 days</Option>
|
||||
<Option value="365d">365 days</Option>
|
||||
<Option value="custom">Custom interval</Option>
|
||||
</Select>
|
||||
|
||||
{showCustomInput && (
|
||||
<div className="space-y-1">
|
||||
<TextInput
|
||||
value={customInterval}
|
||||
onChange={handleCustomIntervalChange}
|
||||
placeholder="e.g., 1s, 5m, 2h, 14d"
|
||||
/>
|
||||
<div className="text-xs text-gray-500">
|
||||
Supported formats: seconds (s), minutes (m), hours (h), days (d)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||
When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated
|
||||
after a brief grace period.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{autoRotationEnabled && (
|
||||
<div className="bg-blue-50 p-3 rounded-md text-sm text-blue-700">
|
||||
When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated
|
||||
after a brief grace period.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,109 +1,127 @@
|
|||
import React from "react";
|
||||
import { Form, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
const { Option } = Select;
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
type RateLimitType = "tpm" | "rpm";
|
||||
|
||||
interface RateLimitTypeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RateLimitTypeFormItemProps {
|
||||
/** The type of rate limit - either 'tpm' or 'rpm' */
|
||||
type: "tpm" | "rpm";
|
||||
type: RateLimitType;
|
||||
/** The form field name */
|
||||
name: string;
|
||||
/** Whether to show detailed descriptions (default: true) */
|
||||
showDetailedDescriptions?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Initial value for the field */
|
||||
initialValue?: string | null;
|
||||
/** Form instance for setting field values */
|
||||
form?: any;
|
||||
value?: string | null;
|
||||
/** Custom onChange handler */
|
||||
onChange?: (value: string) => void;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
"aria-invalid"?: true | undefined;
|
||||
"aria-describedby"?: string | undefined;
|
||||
}
|
||||
|
||||
const rateLimitTypeOptions = (type: RateLimitType): RateLimitTypeOption[] => {
|
||||
const upper = type.toUpperCase();
|
||||
const lower = type.toLowerCase();
|
||||
return [
|
||||
{
|
||||
value: "best_effort_throughput",
|
||||
label: "Default",
|
||||
description: `Best effort throughput - no error if we're overallocating ${lower} (Team/Key Limits checked at runtime).`,
|
||||
},
|
||||
{
|
||||
value: "guaranteed_throughput",
|
||||
label: "Guaranteed throughput",
|
||||
description: `Guaranteed throughput - raise an error if we're overallocating ${lower} (also checks model-specific limits)`,
|
||||
},
|
||||
{
|
||||
value: "dynamic",
|
||||
label: "Dynamic",
|
||||
description: `If the key has a set ${upper} (e.g. 2 ${upper}) and there are no 429 errors, it can dynamically exceed the limit when the model being called is not erroring.`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const plainLabels: Record<string, string> = {
|
||||
best_effort_throughput: "Best effort throughput",
|
||||
guaranteed_throughput: "Guaranteed throughput",
|
||||
dynamic: "Dynamic",
|
||||
};
|
||||
|
||||
const rateLimitTypeLabelText = (type: RateLimitType): string => `${type.toUpperCase()} Rate Limit Type`;
|
||||
|
||||
const rateLimitTypeTooltip = (type: RateLimitType): string =>
|
||||
`Select 'guaranteed_throughput' to prevent overallocating ${type.toUpperCase()} limit when the key belongs to a Team with specific ${type.toUpperCase()} limits.`;
|
||||
|
||||
export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
|
||||
type,
|
||||
name,
|
||||
showDetailedDescriptions = true,
|
||||
className = "",
|
||||
initialValue = null,
|
||||
form,
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
disabled,
|
||||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}) => {
|
||||
const limitTypeUpper = type.toUpperCase();
|
||||
const limitTypeLower = type.toLowerCase();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (form) {
|
||||
form.setFieldValue(name, value);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const tooltipTitle = `Select 'guaranteed_throughput' to prevent overallocating ${limitTypeUpper} limit when the key belongs to a Team with specific ${limitTypeUpper} limits.`;
|
||||
const controlId = id ?? `rate-limit-type-${name}`;
|
||||
const options = rateLimitTypeOptions(type);
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
{limitTypeUpper} Rate Limit Type{" "}
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
<div className={className}>
|
||||
<TooltipProvider>
|
||||
<label htmlFor={controlId} className="mb-2 flex items-center gap-1 text-sm text-foreground">
|
||||
{rateLimitTypeLabelText(type)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />}
|
||||
aria-label={rateLimitTypeTooltip(type)}
|
||||
/>
|
||||
<TooltipContent>{rateLimitTypeTooltip(type)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={name}
|
||||
initialValue={initialValue}
|
||||
className={className}
|
||||
>
|
||||
</label>
|
||||
</TooltipProvider>
|
||||
<Select
|
||||
defaultValue={showDetailedDescriptions ? "default" : undefined}
|
||||
placeholder="Select rate limit type"
|
||||
style={{ width: "100%" }}
|
||||
optionLabelProp={showDetailedDescriptions ? "label" : undefined}
|
||||
onChange={handleChange}
|
||||
value={value ?? null}
|
||||
onValueChange={(next: string | null) => next !== null && onChange?.(next)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showDetailedDescriptions ? (
|
||||
<>
|
||||
<Option value="best_effort_throughput" label="Default">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Default</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
Best effort throughput - no error if we're overallocating {limitTypeLower} (Team/Key Limits
|
||||
checked at runtime).
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="guaranteed_throughput" label="Guaranteed throughput">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Guaranteed throughput</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
Guaranteed throughput - raise an error if we're overallocating {limitTypeLower} (also checks
|
||||
model-specific limits)
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="dynamic" label="Dynamic">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Dynamic</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
If the key has a set {limitTypeUpper} (e.g. 2 {limitTypeUpper}) and there are no 429 errors, it can
|
||||
dynamically exceed the limit when the model being called is not erroring.
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Option value="best_effort_throughput">Best effort throughput</Option>
|
||||
<Option value="guaranteed_throughput">Guaranteed throughput</Option>
|
||||
<Option value="dynamic">Dynamic</Option>
|
||||
</>
|
||||
)}
|
||||
<SelectTrigger id={controlId} className="w-full" aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
|
||||
<SelectValue placeholder="Select rate limit type">
|
||||
{(selected: string | null) =>
|
||||
selected === null ? "Select rate limit type" : plainLabels[selected] ?? selected
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) =>
|
||||
showDetailedDescriptions ? (
|
||||
<SelectItem key={option.value} value={option.value} title={option.label}>
|
||||
<span className="flex flex-col py-1">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="mt-0.5 text-[11px] text-muted-foreground">{option.description}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
) : (
|
||||
<SelectItem key={option.value} value={option.value} title={plainLabels[option.value]}>
|
||||
{plainLabels[option.value]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
|
||||
import CreateKey from "./create_key_button";
|
||||
|
||||
const { mockKeyCreateCall } = vi.hoisted(() => ({
|
||||
mockKeyCreateCall: vi.fn().mockResolvedValue({ key: "sk-created", soft_budget: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({ accessToken: "test-token", userId: "test-user-id", userRole: "Admin", premiumUser: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ default: () => false }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ keyKeys: { lists: () => ["keys"] } }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
|
||||
useProjects: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => ({ data: { values: {} } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({ useTags: () => ({ data: {} }) }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useInfiniteTeams: () => ({ data: { pages: [{ teams: [] }] }, fetchNextPage: vi.fn(), hasNextPage: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
|
||||
useAccessGroups: () => ({ data: [], isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
keyCreateCall: mockKeyCreateCall,
|
||||
keyCreateServiceAccountCall: vi.fn().mockResolvedValue({ key: "sk-sa", soft_budget: null }),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }),
|
||||
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
|
||||
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
|
||||
getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }),
|
||||
getPossibleUserRoles: vi.fn().mockResolvedValue({}),
|
||||
userFilterUICall: vi.fn().mockResolvedValue([]),
|
||||
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
|
||||
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
|
||||
proxyBaseUrl: "http://localhost:4000",
|
||||
}));
|
||||
|
||||
vi.mock("../agent_management/AgentSelector", () => ({ default: () => null }));
|
||||
vi.mock("../common_components/check_openapi_schema", () => ({ default: () => null }));
|
||||
vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null }));
|
||||
vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null }));
|
||||
vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null }));
|
||||
vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null }));
|
||||
vi.mock("../vector_store_management/VectorStoreSelector", () => ({ default: () => null }));
|
||||
vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null }));
|
||||
|
||||
const MINIMAL_CREATE_PAYLOAD = {
|
||||
organization_id: undefined,
|
||||
team_id: null,
|
||||
key_alias: "probe-key",
|
||||
models: [],
|
||||
key_type: "llm_api",
|
||||
user_id: "test-user-id",
|
||||
duration: null,
|
||||
metadata: "{}",
|
||||
};
|
||||
|
||||
describe("CreateKey submit payload contract", () => {
|
||||
beforeEach(() => {
|
||||
mockKeyCreateCall.mockClear();
|
||||
});
|
||||
|
||||
const openModal = async () => {
|
||||
renderWithProviders(<CreateKey team={null} teams={[]} data={[]} addKey={() => {}} />);
|
||||
await userEvent.click(screen.getAllByTestId("create-key-button")[0]);
|
||||
await screen.findByRole("button", { name: /create key/i });
|
||||
};
|
||||
|
||||
it("sends exactly the bound form fields for a minimal create", async () => {
|
||||
await openModal();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockKeyCreateCall.mock.calls[0][2]).toStrictEqual(MINIMAL_CREATE_PAYLOAD);
|
||||
});
|
||||
|
||||
it("keeps a collapsed Optional Settings section out of the payload entirely", async () => {
|
||||
await openModal();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
const payload = mockKeyCreateCall.mock.calls[0][2];
|
||||
expect(payload).not.toHaveProperty("tpm_limit_type");
|
||||
expect(payload).not.toHaveProperty("rpm_limit_type");
|
||||
expect(payload).not.toHaveProperty("max_budget");
|
||||
});
|
||||
|
||||
it("carries the shared rate-limit-type control into the payload once its section is open", async () => {
|
||||
await openModal();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
|
||||
await userEvent.click(screen.getByText("Optional Settings"));
|
||||
|
||||
await userEvent.click(await screen.findByLabelText(/TPM Rate Limit Type/));
|
||||
await userEvent.click(await screen.findByText("Guaranteed throughput"));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockKeyCreateCall.mock.calls[0][2]).toMatchObject({
|
||||
tpm_limit_type: "guaranteed_throughput",
|
||||
rpm_limit_type: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the shared lifecycle expiry into the payload once its section is open", async () => {
|
||||
await openModal();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
|
||||
await userEvent.click(screen.getByText("Optional Settings"));
|
||||
await userEvent.click(await screen.findByText("Key Lifecycle"));
|
||||
|
||||
await userEvent.type(await screen.findByLabelText("Expire Key"), "45d");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockKeyCreateCall.mock.calls[0][2]).toMatchObject({ duration: "45d" });
|
||||
});
|
||||
|
||||
it("preserves a value typed in a section that is collapsed and reopened before submit", async () => {
|
||||
await openModal();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Key Name/), "probe-key");
|
||||
await userEvent.click(screen.getByText("Optional Settings"));
|
||||
|
||||
const maxBudget = await screen.findByLabelText(/Max Budget/);
|
||||
await userEvent.type(maxBudget, "150.75");
|
||||
|
||||
await userEvent.click(screen.getByText("Optional Settings"));
|
||||
await userEvent.click(screen.getByText("Optional Settings"));
|
||||
|
||||
expect(await screen.findByLabelText(/Max Budget/)).toHaveValue(150.75);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /create key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockKeyCreateCall.mock.calls[0][2]).toMatchObject({ max_budget: "150.75" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1128,14 +1128,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<RateLimitTypeFormItem
|
||||
type="tpm"
|
||||
name="tpm_limit_type"
|
||||
className="mt-4"
|
||||
initialValue={null}
|
||||
form={form}
|
||||
showDetailedDescriptions={true}
|
||||
/>
|
||||
<Form.Item name="tpm_limit_type" initialValue={null} noStyle>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_limit_type" className="mt-4" showDetailedDescriptions />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
|
|
@ -1160,14 +1155,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<RateLimitTypeFormItem
|
||||
type="rpm"
|
||||
name="rpm_limit_type"
|
||||
className="mt-4"
|
||||
initialValue={null}
|
||||
form={form}
|
||||
showDetailedDescriptions={true}
|
||||
/>
|
||||
<Form.Item name="rpm_limit_type" initialValue={null} noStyle>
|
||||
<RateLimitTypeFormItem type="rpm" name="rpm_limit_type" className="mt-4" showDetailedDescriptions />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
|
|
@ -1633,14 +1623,15 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="mt-4">
|
||||
<KeyLifecycleSettings
|
||||
form={form}
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
isCreateMode={true}
|
||||
/>
|
||||
<Form.Item name="duration" initialValue="" noStyle>
|
||||
<KeyLifecycleSettings
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
isCreateMode={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -1649,4 +1649,135 @@ describe("KeyEditView", () => {
|
|||
expect(screen.getByLabelText("Estimated Output Tokens Per Model")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
const UNTOUCHED_SAVE_PAYLOAD = {
|
||||
key_alias: "asdasdas",
|
||||
models: [],
|
||||
max_budget: 0,
|
||||
budget_duration: "30d",
|
||||
tpm_limit: 10,
|
||||
tpm_limit_type: null,
|
||||
rpm_limit: 10,
|
||||
rpm_limit_type: null,
|
||||
throttle_on_budget_exceeded: false,
|
||||
enable_prompt_caching: false,
|
||||
max_parallel_requests: 10,
|
||||
model_tpm_limit: undefined,
|
||||
model_rpm_limit: undefined,
|
||||
guardrails: undefined,
|
||||
disable_global_guardrails: false,
|
||||
policies: undefined,
|
||||
tags: ["test-tag"],
|
||||
prompts: undefined,
|
||||
access_group_ids: [],
|
||||
allowed_passthrough_routes: undefined,
|
||||
vector_stores: [],
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
mcp_tool_permissions: {},
|
||||
agents_and_groups: { agents: [], accessGroups: [] },
|
||||
organization_id: null,
|
||||
team_id: null,
|
||||
logging_settings: [],
|
||||
metadata: "{}",
|
||||
duration: "30d",
|
||||
token: "test-token-123",
|
||||
disabled_callbacks: [],
|
||||
auto_rotate: false,
|
||||
rotation_interval: undefined,
|
||||
tag_rpm_limit: {},
|
||||
};
|
||||
|
||||
describe("submit payload contract", () => {
|
||||
const renderForPayload = (
|
||||
onSubmit: (values: Record<string, unknown>) => Promise<void>,
|
||||
keyData: KeyResponse = MOCK_KEY_DATA,
|
||||
) =>
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyData}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"Admin"}
|
||||
premiumUser={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
it("sends exactly the bound form fields on an untouched save, and no server-only key data", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock);
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSubmitMock.mock.calls[0][0]).toStrictEqual(UNTOUCHED_SAVE_PAYLOAD);
|
||||
});
|
||||
|
||||
it("drops the policy and prompt keys entirely for a role that cannot see those fields", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"Internal User"}
|
||||
premiumUser={true}
|
||||
/>,
|
||||
);
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
const payload = onSubmitMock.mock.calls[0][0];
|
||||
expect(payload).not.toHaveProperty("policies");
|
||||
expect(payload).not.toHaveProperty("prompts");
|
||||
expect(payload).toHaveProperty("guardrails");
|
||||
});
|
||||
|
||||
it("routes the shared lifecycle and rate-limit-type controls into their own payload keys", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock);
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
const duration = screen.getByPlaceholderText("e.g., 30d");
|
||||
await userEvent.clear(duration);
|
||||
await userEvent.type(duration, "45d");
|
||||
|
||||
await userEvent.click(screen.getByLabelText(/TPM Rate Limit Type/));
|
||||
await userEvent.click(await screen.findByTitle("Guaranteed throughput"));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
const payload = onSubmitMock.mock.calls[0][0];
|
||||
expect(payload.duration).toBe("45d");
|
||||
expect(payload.tpm_limit_type).toBe("guaranteed_throughput");
|
||||
expect(payload.rpm_limit_type).toBeNull();
|
||||
});
|
||||
|
||||
it("blanks duration rather than dropping the key when Never Expire is ticked", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderForPayload(onSubmitMock, { ...MOCK_KEY_DATA, expires: "2026-01-01T00:00:00Z" });
|
||||
await screen.findByRole("button", { name: /save changes/i });
|
||||
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /never expire/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -493,13 +493,17 @@ export function KeyEditView({
|
|||
<NumericalInput min={0} />
|
||||
</Form.Item>
|
||||
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_limit_type" showDetailedDescriptions={false} />
|
||||
<Form.Item name="tpm_limit_type" initialValue={null} noStyle>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_limit_type" showDetailedDescriptions={false} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="RPM Limit" name="rpm_limit">
|
||||
<NumericalInput min={0} />
|
||||
</Form.Item>
|
||||
|
||||
<RateLimitTypeFormItem type="rpm" name="rpm_limit_type" showDetailedDescriptions={false} />
|
||||
<Form.Item name="rpm_limit_type" initialValue={null} noStyle>
|
||||
<RateLimitTypeFormItem type="rpm" name="rpm_limit_type" showDetailedDescriptions={false} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
|
|
@ -840,15 +844,16 @@ export function KeyEditView({
|
|||
|
||||
{/* Auto-Rotation Settings */}
|
||||
<div className="mb-4">
|
||||
<KeyLifecycleSettings
|
||||
form={form}
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
neverExpire={neverExpire}
|
||||
onNeverExpireChange={setNeverExpire}
|
||||
/>
|
||||
<Form.Item name="duration" initialValue="" noStyle>
|
||||
<KeyLifecycleSettings
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
neverExpire={neverExpire}
|
||||
onNeverExpireChange={setNeverExpire}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Hidden form field for token */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue