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:
yuneng-jiang 2026-08-18 14:28:03 -07:00 committed by GitHub
parent 76ff0d5351
commit 573aa61084
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 601 additions and 245 deletions

View file

@ -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"));

View file

@ -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&apos;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&apos;ll receive a notification with the new key. The old key will be deactivated
after a brief grace period.
</div>
)}
</div>
</div>
</TooltipProvider>
);
};

View file

@ -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&apos;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&apos;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>
);
};

View file

@ -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" });
});
});

View file

@ -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>

View file

@ -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);
});
});
});

View file

@ -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 */}