Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/litellm-logs-ui-lag-0ca4b8

This commit is contained in:
Yuneng Jiang 2026-07-24 16:21:50 -07:00
commit 12bd6b5e5b
No known key found for this signature in database
22 changed files with 752 additions and 543 deletions

View file

@ -7,6 +7,14 @@
assertions: [succeeds]
source: "server.py:637"
rationale: Core operation; most common auth path; high usage
- id: mcp.list_tools.api_key.access_group_scoped
module: mcp
tier: P1
operation: list_tools
auth_family: api_key
assertions: [access_group_scoped]
source: "test_mcp_access_group_e2e.py"
rationale: "A key granted an MCP access group sees the tagged server's tools; a key with a different group does not. Access-group-scoped tool selection at key creation"
- id: mcp.list_tools.api_key.denied_without_permission
module: mcp
tier: P0

View file

@ -30,7 +30,12 @@ def assert_dd_mcp_creds() -> None:
)
def register_datadog_mcp(client: McpClient, resources: ResourceManager) -> str:
def register_datadog_mcp(
client: McpClient,
resources: ResourceManager,
*,
mcp_access_groups: list[str] | None = None,
) -> str:
assert_dd_mcp_creds()
name = f"e2e_dd_mcp_{unique_marker()}"
server_id = client.register_server(
@ -43,6 +48,7 @@ def register_datadog_mcp(client: McpClient, resources: ResourceManager) -> str:
"DD-APPLICATION-KEY": _dd_app_key(),
},
allowed_tools=[SEARCH_LOGS_TOOL],
mcp_access_groups=mcp_access_groups,
)
resources.defer(lambda: client.delete_server(server_id))
return server_id

View file

@ -36,6 +36,7 @@ class McpServerNewBody(BaseModel):
auth_type: str | None = None
static_headers: dict[str, str] | None = None
allowed_tools: list[str] | None = None
mcp_access_groups: list[str] | None = None
class McpServerNewResponse(BaseModel):
@ -155,6 +156,7 @@ class McpClient:
auth_type: str | None = None,
static_headers: dict[str, str] | None = None,
allowed_tools: list[str] | None = None,
mcp_access_groups: list[str] | None = None,
) -> str:
return unwrap(
self.proxy.transport.post(
@ -168,6 +170,7 @@ class McpClient:
auth_type=auth_type,
static_headers=static_headers,
allowed_tools=allowed_tools,
mcp_access_groups=mcp_access_groups,
),
response_type=McpServerNewResponse,
)
@ -196,10 +199,13 @@ class McpClient:
*,
user_id: str,
mcp_servers: list[str] | None,
mcp_access_groups: list[str] | None = None,
models: list[str] | None = None,
) -> str:
object_permission = (
ObjectPermission(mcp_servers=mcp_servers) if mcp_servers is not None else None
ObjectPermission(mcp_servers=mcp_servers, mcp_access_groups=mcp_access_groups)
if mcp_servers is not None or mcp_access_groups is not None
else None
)
return self.proxy.generate_key(
KeyGenerateBody(

View file

@ -0,0 +1,58 @@
"""Live e2e: MCP tool selection via access group at key creation.
An admin registers the Datadog remote MCP server tagged with a server-side
access group (`mcp_access_groups`). A key minted with that access group
(`object_permission.mcp_access_groups`) sees the server's tools; a key minted
with a different group does not. This exercises access-group-scoped tool
selection, the enterprise MCP surface where keys are granted tool access groups
rather than explicit server ids.
A tools/list that leaks the server across the access-group boundary fails hard.
Requires DD_API_KEY + DD_APP_KEY (the suite's real MCP upstream).
"""
import pytest
from datadog_mcp import SEARCH_LOGS_TOOL, register_datadog_mcp
from e2e_config import unique_marker
from e2e_http import unwrap
from lifecycle import ResourceManager
from mcp_client import McpClient
pytestmark = pytest.mark.e2e
class TestMcpAccessGroupToolSelection:
@pytest.mark.covers("mcp.list_tools.api_key.access_group_scoped")
def test_access_group_scopes_tool_selection(
self, client: McpClient, resources: ResourceManager
) -> None:
group = f"e2e-mcp-grp-{unique_marker()}"
server_id = register_datadog_mcp(client, resources, mcp_access_groups=[group])
granted = client.generate_key(
user_id=f"e2e-mcp-ag-granted-{unique_marker()}",
mcp_servers=None,
mcp_access_groups=[group],
)
resources.defer(lambda: client.proxy.delete_key(granted))
other = client.generate_key(
user_id=f"e2e-mcp-ag-other-{unique_marker()}",
mcp_servers=None,
mcp_access_groups=[f"e2e-mcp-grp-absent-{unique_marker()}"],
)
resources.defer(lambda: client.proxy.delete_key(other))
granted_tools = unwrap(client.list_tools(granted))
assert granted_tools.tool_name_containing(server_id, SEARCH_LOGS_TOOL) is not None, (
f"key granted access group {group} did not see the tagged server's tool "
f"(upstream dead or access-group grant not applied): "
f"{granted_tools.tool_names_for_server(server_id)}"
)
other_tools = unwrap(client.list_tools(other)).tool_names_for_server(server_id)
assert other_tools == frozenset(), (
f"key with a different access group saw the server's tools; access-group tool "
f"selection leaked across the boundary: {other_tools}"
)

View file

@ -47,6 +47,7 @@ class KeyMetadata(BaseModel):
class ObjectPermission(BaseModel):
mcp_servers: list[str] | None = None
mcp_access_groups: list[str] | None = None
class KeyGenerateBody(BaseModel):

View file

@ -1088,11 +1088,6 @@
"count": 1
}
},
"src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -1,19 +1,14 @@
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels";
import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { organizationCreateCall, organizationDeleteCall } from "@/components/networking";
import { organizationDeleteCall } from "@/components/networking";
import { OrgCreateDialog } from "@/components/organization/org-create/OrgCreateDialog";
import OrganizationInfoView from "@/components/organization/organization_view";
import NumericalInput from "@/components/shared/numerical_input";
import { Button } from "@/components/ui/button";
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
import OrganizationsTable from "./OrganizationsTable";
@ -30,7 +25,6 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isOrgModalVisible, setIsOrgModalVisible] = useState(false);
const [form] = Form.useForm();
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<FilterState>({ org_id: "", org_alias: "" });
@ -83,48 +77,6 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
setOrgToDelete(null);
};
const handleCreate = async (values: any) => {
try {
if (!accessToken) return;
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission
if (
(values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) ||
(values.allowed_mcp_servers_and_groups &&
(values.allowed_mcp_servers_and_groups.servers?.length > 0 ||
values.allowed_mcp_servers_and_groups.accessGroups?.length > 0))
) {
values.object_permission = {};
if (values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) {
values.object_permission.vector_stores = values.allowed_vector_store_ids;
delete values.allowed_vector_store_ids;
}
if (values.allowed_mcp_servers_and_groups) {
if (values.allowed_mcp_servers_and_groups.servers?.length > 0) {
values.object_permission.mcp_servers = values.allowed_mcp_servers_and_groups.servers;
}
if (values.allowed_mcp_servers_and_groups.accessGroups?.length > 0) {
values.object_permission.mcp_access_groups = values.allowed_mcp_servers_and_groups.accessGroups;
}
delete values.allowed_mcp_servers_and_groups;
}
}
await organizationCreateCall(accessToken, values);
NotificationsManager.success("Organization created successfully");
setIsOrgModalVisible(false);
form.resetFields();
await refetchOrganizations();
} catch (error) {
console.error("Error creating organization:", error);
}
};
const handleCancel = () => {
setIsOrgModalVisible(false);
form.resetFields();
};
if (!premiumUser) {
return (
<div className="mx-4 mt-4">
@ -190,97 +142,7 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
</>
)}
<Modal title="Create Organization" visible={isOrgModalVisible} width={800} footer={null} onCancel={handleCancel}>
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<Form.Item
label="Organization Name"
name="organization_alias"
rules={[
{
required: true,
message: "Please input an organization name",
},
]}
>
<Input placeholder="" />
</Form.Item>
<Form.Item label="Models" name="models">
<ModelSelect
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
value={form.getFieldValue("models")}
onChange={(values) => form.setFieldValue("models", values)}
context="organization"
/>
</Form.Item>
<Form.Item label="Max Budget (USD)" name="max_budget">
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item label="Reset Budget" name="budget_duration">
<Select2 defaultValue={null} placeholder="n/a">
<Select2.Option value="24h">daily</Select2.Option>
<Select2.Option value="7d">weekly</Select2.Option>
<Select2.Option value="30d">monthly</Select2.Option>
</Select2>
</Form.Item>
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
label={
<span>
Allowed Vector Stores{" "}
<Tooltip title="Select which vector stores this organization can access by default. Leave empty for access to all vector stores">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_vector_store_ids"
className="mt-4"
help="Select vector stores this organization can access. Leave empty for access to all vector stores"
>
<VectorStoreSelector
onChange={(values) => form.setFieldValue("allowed_vector_store_ids", values)}
value={form.getFieldValue("allowed_vector_store_ids")}
accessToken={accessToken || ""}
placeholder="Select vector stores (optional)"
/>
</Form.Item>
<Form.Item
label={
<span>
Allowed MCP Servers{" "}
<Tooltip title="Select which MCP servers and access groups this organization can access by default.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_mcp_servers_and_groups"
className="mt-4"
help="Select MCP servers and access groups this organization can access."
>
<MCPServerSelector
onChange={(values) => form.setFieldValue("allowed_mcp_servers_and_groups", values)}
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
accessToken={accessToken || ""}
placeholder="Select MCP servers and access groups (optional)"
/>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="submit">Create Organization</Button>
</div>
</Form>
</Modal>
<OrgCreateDialog open={isOrgModalVisible} onOpenChange={setIsOrgModalVisible} accessToken={accessToken || ""} />
<DeleteResourceModal
isOpen={isDeleteModalOpen}

View file

@ -1,14 +1,12 @@
import type { components } from "@/lib/http/schema";
export interface AgentAttachedKey {
token: string;
key_alias?: string | null;
key_name?: string | null;
}
export interface AgentObjectPermission {
mcp_servers?: string[];
mcp_access_groups?: string[];
mcp_tool_permissions?: Record<string, string[]>;
}
export type AgentObjectPermission = components["schemas"]["AgentObjectPermission"];
export interface Agent {
agent_id: string;

View file

@ -1,357 +1,214 @@
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 { describe, expect, it, vi, beforeEach } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import KeyLifecycleSettings from "./KeyLifecycleSettings";
vi.mock("antd", () => {
const Option = ({ children, value }: any) => <option value={value}>{children}</option>;
const Select = ({ children, value, onChange, placeholder }: any) => (
<select
data-testid="select"
value={value}
onChange={(e) => onChange(e.target.value)}
data-placeholder={placeholder}
>
{children}
</select>
);
Select.Option = Option;
return {
Select,
Tooltip: ({ children, title }: any) => (
<div data-testid="tooltip" title={title}>
{children}
</div>
),
Switch: ({ checked, onChange }: any) => (
<input type="checkbox" data-testid="switch" checked={checked} onChange={(e) => onChange(e.target.checked)} />
),
Divider: () => <hr data-testid="divider" />,
};
});
const CREATE_PLACEHOLDER = "e.g., 30d or leave empty to never expire";
const EDIT_PLACEHOLDER = "e.g., 30d";
vi.mock("@ant-design/icons", () => ({
InfoCircleOutlined: () => <span data-testid="info-icon"></span>,
}));
interface HarnessProps {
isCreateMode?: boolean;
onFinish?: (values: Record<string, unknown>) => void;
}
vi.mock("@tremor/react", () => ({
TextInput: ({ value, onValueChange, onChange, placeholder, name, className }: any) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
};
return (
<input
data-testid={name === "duration" ? "duration-input" : "custom-interval-input"}
value={value}
onChange={handleChange}
placeholder={placeholder}
className={className}
const Harness: React.FC<HarnessProps> = ({ isCreateMode = true, onFinish = () => {} }) => {
const [form] = Form.useForm();
const [autoRotationEnabled, setAutoRotationEnabled] = useState(false);
const [rotationInterval, setRotationInterval] = useState("");
const [neverExpire, setNeverExpire] = useState(false);
return (
<Form form={form} onFinish={onFinish}>
<KeyLifecycleSettings
form={form}
autoRotationEnabled={autoRotationEnabled}
onAutoRotationChange={setAutoRotationEnabled}
rotationInterval={rotationInterval}
onRotationIntervalChange={setRotationInterval}
isCreateMode={isCreateMode}
neverExpire={neverExpire}
onNeverExpireChange={setNeverExpire}
/>
);
},
}));
<button type="submit">submit</button>
<button type="button" onClick={() => form.resetFields()}>
reset
</button>
<span data-testid="rotation-interval-value">{rotationInterval}</span>
</Form>
);
};
const getDurationInput = (isCreateMode = true) =>
screen.getByPlaceholderText(isCreateMode ? CREATE_PLACEHOLDER : EDIT_PLACEHOLDER) as HTMLInputElement;
describe("KeyLifecycleSettings", () => {
const mockForm = {
getFieldValue: vi.fn(),
setFieldValue: vi.fn(),
setFieldsValue: vi.fn(),
};
const defaultProps = {
form: mockForm,
autoRotationEnabled: false,
onAutoRotationChange: vi.fn(),
rotationInterval: "",
onRotationIntervalChange: vi.fn(),
isCreateMode: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockForm.getFieldValue.mockReturnValue("");
});
it("should render without crashing", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
it("renders the expiry and auto-rotation sections", () => {
renderWithProviders(<Harness />);
expect(screen.getByText("Key Expiry Settings")).toBeInTheDocument();
expect(screen.getByText("Auto-Rotation Settings")).toBeInTheDocument();
expect(getDurationInput()).toBeInTheDocument();
});
describe("Key Expiry Settings", () => {
it("should render expiry input field", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
it("uses the create-mode placeholder in create mode", () => {
renderWithProviders(<Harness isCreateMode={true} />);
expect(screen.getByPlaceholderText(CREATE_PLACEHOLDER)).toBeInTheDocument();
});
expect(screen.getByText("Expire Key")).toBeInTheDocument();
expect(screen.getByTestId("duration-input")).toBeInTheDocument();
});
it("uses the edit-mode placeholder in edit mode", () => {
renderWithProviders(<Harness isCreateMode={false} />);
expect(screen.getByPlaceholderText(EDIT_PLACEHOLDER)).toBeInTheDocument();
});
it("should show correct placeholder in create mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
const input = screen.getByTestId("duration-input");
expect(input).toHaveAttribute("placeholder", "e.g., 30d or leave empty to never expire");
});
it("should show correct placeholder in edit mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
const input = screen.getByTestId("duration-input");
expect(input).toHaveAttribute("placeholder", "e.g., 30d");
});
it("should show correct tooltip in create mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
const tooltips = screen.getAllByTestId("tooltip");
const expiryTooltip = tooltips.find((tooltip) =>
tooltip.getAttribute("title")?.includes("Leave empty to keep the current expiry unchanged"),
);
expect(expiryTooltip).toBeInTheDocument();
expect(expiryTooltip).toHaveAttribute(
"title",
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged.",
);
});
it("should show correct tooltip in edit mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
const tooltips = screen.getAllByTestId("tooltip");
const expiryTooltip = tooltips.find((tooltip) =>
tooltip.getAttribute("title")?.includes("Leave empty to keep the current expiry unchanged"),
);
expect(expiryTooltip).toBeInTheDocument();
expect(expiryTooltip).toHaveAttribute(
"title",
"Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to keep the current expiry unchanged.",
);
});
it("should initialize with form value if present", () => {
mockForm.getFieldValue.mockReturnValue("30d");
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
const input = screen.getByTestId("duration-input") as HTMLInputElement;
expect(input.value).toBe("30d");
});
it("should update form using setFieldValue when duration changes", async () => {
describe("duration is a single source of truth (regression for pre-filled value dropped on submit)", () => {
it("submits the duration the user typed", async () => {
const user = userEvent.setup();
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
const onFinish = vi.fn();
renderWithProviders(<Harness onFinish={onFinish} />);
const input = screen.getByTestId("duration-input");
await user.type(input, "60d");
await user.type(getDurationInput(), "1d");
await user.click(screen.getByRole("button", { name: "submit" }));
expect(mockForm.setFieldValue).toHaveBeenCalledWith("duration", "60d");
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onFinish.mock.calls[0][0]).toMatchObject({ duration: "1d" });
});
it("should update form using setFieldsValue when setFieldValue is not available", async () => {
it("clears the displayed value when the form is reset, so no stale value lingers", async () => {
const user = userEvent.setup();
const formWithoutSetFieldValue = {
getFieldValue: vi.fn().mockReturnValue(""),
setFieldsValue: vi.fn(),
};
renderWithProviders(<KeyLifecycleSettings {...defaultProps} form={formWithoutSetFieldValue} />);
renderWithProviders(<Harness />);
const input = screen.getByTestId("duration-input");
await user.type(input, "90d");
await user.type(getDurationInput(), "1d");
expect(getDurationInput().value).toBe("1d");
expect(formWithoutSetFieldValue.setFieldsValue).toHaveBeenCalledWith({ duration: "90d" });
await user.click(screen.getByRole("button", { name: "reset" }));
await waitFor(() => expect(getDurationInput().value).toBe(""));
});
it("never submits a value that differs from what is displayed after a reset", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
renderWithProviders(<Harness onFinish={onFinish} />);
// First create: type "1d" and submit -> "1d" is sent.
await user.type(getDurationInput(), "1d");
await user.click(screen.getByRole("button", { name: "submit" }));
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onFinish.mock.calls[0][0]).toMatchObject({ duration: "1d" });
// Second create: form resets, so the field must show empty AND submit empty.
// The old bug showed a stale "1d" while submitting null/empty.
await user.click(screen.getByRole("button", { name: "reset" }));
await waitFor(() => expect(getDurationInput().value).toBe(""));
await user.click(screen.getByRole("button", { name: "submit" }));
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(2));
expect(onFinish.mock.calls[1][0].duration).not.toBe("1d");
expect(getDurationInput().value).toBe(onFinish.mock.calls[1][0].duration ?? "");
});
});
describe("Auto-Rotation Settings", () => {
it("should render auto-rotation switch", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} />);
expect(screen.getByText("Enable Auto-Rotation")).toBeInTheDocument();
expect(screen.getByTestId("switch")).toBeInTheDocument();
});
it("should show switch as unchecked when autoRotationEnabled is false", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
const switchElement = screen.getByTestId("switch") as HTMLInputElement;
expect(switchElement.checked).toBe(false);
});
it("should show switch as checked when autoRotationEnabled is true", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
const switchElement = screen.getByTestId("switch") as HTMLInputElement;
expect(switchElement.checked).toBe(true);
});
it("should call onAutoRotationChange when switch is toggled", async () => {
describe("Never Expire", () => {
it("clears and disables the duration input, then submits an empty duration", async () => {
const user = userEvent.setup();
const onAutoRotationChange = vi.fn();
renderWithProviders(<KeyLifecycleSettings {...defaultProps} onAutoRotationChange={onAutoRotationChange} />);
const onFinish = vi.fn();
renderWithProviders(<Harness isCreateMode={false} onFinish={onFinish} />);
const switchElement = screen.getByTestId("switch");
await user.click(switchElement);
await user.type(getDurationInput(false), "30d");
expect(getDurationInput(false).value).toBe("30d");
expect(onAutoRotationChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole("checkbox", { name: /never expire/i }));
await waitFor(() => expect(getDurationInput(false).value).toBe(""));
expect(getDurationInput(false)).toBeDisabled();
await user.click(screen.getByRole("button", { name: "submit" }));
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onFinish.mock.calls[0][0]).toMatchObject({ duration: "" });
});
});
it("should not show rotation interval section when auto-rotation is disabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
describe("Auto-Rotation", () => {
it("reveals the rotation interval controls when enabled", async () => {
const user = userEvent.setup();
renderWithProviders(<Harness />);
expect(screen.queryByText("Rotation Interval")).not.toBeInTheDocument();
expect(screen.queryByTestId("select")).not.toBeInTheDocument();
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
});
it("should show rotation interval section when auto-rotation is enabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />);
expect(screen.getByText("Rotation Interval")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
});
it("should show all predefined interval options", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />);
expect(screen.getByText("7 days")).toBeInTheDocument();
expect(screen.getByText("30 days")).toBeInTheDocument();
expect(screen.getByText("90 days")).toBeInTheDocument();
expect(screen.getByText("180 days")).toBeInTheDocument();
expect(screen.getByText("365 days")).toBeInTheDocument();
expect(screen.getByText("Custom interval")).toBeInTheDocument();
});
it("should display current rotation interval in select", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="90d" />);
const select = screen.getByTestId("select") as HTMLSelectElement;
expect(select.value).toBe("90d");
});
it("should call onRotationIntervalChange when predefined interval is selected", async () => {
it("propagates a selected predefined interval", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="7d"
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "30d");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(onRotationIntervalChange).toHaveBeenCalledWith("30d");
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("90 days"));
await waitFor(() => expect(document.querySelector(".ant-select-selection-item")?.textContent).toBe("90 days"));
expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("90d");
});
it("should show custom input when custom option is selected", async () => {
it("shows the custom interval input when Custom interval is selected, without propagating yet", async () => {
const user = userEvent.setup();
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "custom");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument();
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Custom interval"));
expect(await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d")).toBeInTheDocument();
expect(screen.getByText("Supported formats: seconds (s), minutes (m), hours (h), days (d)")).toBeInTheDocument();
expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("");
});
it("should hide custom input when predefined interval is selected after custom", async () => {
it("propagates a typed custom interval to the parent", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="custom-value"
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "7d");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(screen.queryByTestId("custom-interval-input")).not.toBeInTheDocument();
expect(onRotationIntervalChange).toHaveBeenCalledWith("7d");
});
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Custom interval"));
it("should call onRotationIntervalChange when custom interval is entered", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval=""
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
const select = screen.getByTestId("select");
await user.selectOptions(select, "custom");
const customInput = screen.getByTestId("custom-interval-input");
const customInput = await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d");
await user.type(customInput, "14d");
expect(onRotationIntervalChange).toHaveBeenCalledWith("14d");
await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("14d"));
expect((customInput as HTMLInputElement).value).toBe("14d");
});
it("should show info message when auto-rotation is enabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
expect(
screen.getByText(
"When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period.",
),
).toBeInTheDocument();
});
it("should not show info message when auto-rotation is disabled", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
expect(
screen.queryByText(
"When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period.",
),
).not.toBeInTheDocument();
});
it("should initialize with custom interval input visible when custom interval is provided", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />);
expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument();
const customInput = screen.getByTestId("custom-interval-input") as HTMLInputElement;
expect(customInput.value).toBe("14d");
});
it("should show custom option selected when custom interval is provided", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />);
const select = screen.getByTestId("select") as HTMLSelectElement;
expect(select.value).toBe("custom");
});
it("should not call onRotationIntervalChange when selecting custom option", async () => {
it("hides the custom input and propagates the value when switching back to a predefined interval", async () => {
const user = userEvent.setup();
const onRotationIntervalChange = vi.fn();
renderWithProviders(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="30d"
onRotationIntervalChange={onRotationIntervalChange}
/>,
);
renderWithProviders(<Harness />);
const select = screen.getByTestId("select");
await user.selectOptions(select, "custom");
await user.click(screen.getByRole("switch"));
await waitFor(() => expect(screen.getByText("Rotation Interval")).toBeInTheDocument());
expect(onRotationIntervalChange).not.toHaveBeenCalled();
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Custom interval"));
const customInput = await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d");
await user.type(customInput, "14d");
await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("14d"));
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("7 days"));
await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("7d"));
expect(screen.queryByPlaceholderText("e.g., 1s, 5m, 2h, 14d")).not.toBeInTheDocument();
});
});
});

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Select, Tooltip, Divider, Switch, Checkbox } from "antd";
import { Select, Tooltip, Divider, Switch, Checkbox, Form } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { TextInput } from "@tremor/react";
@ -34,7 +34,6 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
const [showCustomInput, setShowCustomInput] = useState(isCustomInterval);
const [customInterval, setCustomInterval] = useState(isCustomInterval ? rotationInterval : "");
const [durationValue, setDurationValue] = useState<string>(form?.getFieldValue?.("duration") || "");
const handleIntervalChange = (value: string) => {
if (value === "custom") {
@ -53,14 +52,6 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
onRotationIntervalChange(value);
};
const handleDurationChange = (value: string) => {
setDurationValue(value);
if (form && typeof form.setFieldValue === "function") {
form.setFieldValue("duration", value);
} else if (form && typeof form.setFieldsValue === "function") {
form.setFieldsValue({ duration: value });
}
};
return (
<div className="space-y-6">
{/* Key Expiry Section */}
@ -80,7 +71,6 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
const checked = e.target.checked;
onNeverExpireChange(checked);
if (checked) {
setDurationValue("");
if (form && typeof form.setFieldValue === "function") {
form.setFieldValue("duration", "");
} else if (form && typeof form.setFieldsValue === "function") {
@ -94,14 +84,13 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
</Checkbox>
)}
</label>
<TextInput
name="duration"
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d"}
className="w-full"
value={durationValue}
onValueChange={handleDurationChange}
disabled={!isCreateMode && neverExpire}
/>
<Form.Item name="duration" noStyle initialValue="">
<TextInput
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d"}
className="w-full"
disabled={!isCreateMode && neverExpire}
/>
</Form.Item>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { Setter } from "@/types";
import { useEffect, useState } from "react";
import { keyListCall, Member, Organization } from "../networking";
import type { ObjectPermission } from "../object_permission_types";
export interface Team {
team_id: string;
@ -90,16 +91,7 @@ export interface KeyResponse {
user_tpm_limit: number;
user_rpm_limit: number;
user_email: string;
object_permission?: {
object_permission_id: string;
mcp_servers: string[];
mcp_access_groups?: string[];
mcp_toolsets?: string[] | null;
mcp_tool_permissions?: Record<string, string[]>;
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
};
object_permission?: ObjectPermission | null;
access_group_ids?: string[];
budget_fallbacks?: Record<string, string[]>;
budget_limits?: Array<{ budget_duration: string; max_budget: number; reset_at?: string }>;

View file

@ -28,6 +28,7 @@ import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } fro
import { Team } from "./key_team_helpers/key_list";
import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./email_events/types";
import type { SkillRegisterRequest } from "./claude_code_plugins/types";
import type { ObjectPermission } from "./object_permission_types";
import { jsonFields } from "./common_components/check_openapi_schema";
import NotificationsManager from "./molecules/notifications_manager";
import type { MCPUserEnvVarsStatus } from "./mcp_tools/types";
@ -208,13 +209,7 @@ export interface Organization {
teams: any[] | null;
users: any[] | null;
members: any[] | null;
object_permission?: {
object_permission_id: string;
mcp_servers: string[];
mcp_access_groups?: string[];
mcp_toolsets?: string[];
vector_stores: string[];
};
object_permission?: ObjectPermission | null;
}
export interface CredentialItem {
@ -1184,35 +1179,6 @@ export const organizationInfoCall = async (accessToken: string, organizationID:
}
};
export const organizationCreateCall = async (
accessToken: string,
formValues: Record<string, any>, // Assuming formValues is an object
) => {
try {
if (formValues.metadata) {
// if there's an exception JSON.parse, show it in the message
try {
formValues.metadata = JSON.parse(formValues.metadata);
} catch (error) {
console.error("Failed to parse metadata:", error);
throw new Error("Failed to parse metadata: " + error);
}
}
const data = await apiClient.post(`/organization/new`, {
accessToken,
body: {
...formValues, // Include formValues in the request body
},
});
return data;
// Handle success - you might want to update some state or UI based on the created key
} catch (error) {
console.error("Failed to create key:", error);
throw error;
}
};
export const organizationUpdateCall = async (
accessToken: string,
formValues: Record<string, any>, // Assuming formValues is an object

View file

@ -0,0 +1,3 @@
import type { components } from "@/lib/http/schema";
export type ObjectPermission = Partial<components["schemas"]["LiteLLM_ObjectPermissionTable"]>;

View file

@ -3,21 +3,10 @@ import { Text } from "@tremor/react";
import VectorStorePermissions from "./permissions/VectorStorePermissions";
import MCPServerPermissions from "./permissions/MCPServerPermissions";
import AgentPermissions from "./permissions/AgentPermissions";
interface ObjectPermission {
object_permission_id: string;
mcp_servers: string[];
mcp_access_groups?: string[];
mcp_tool_permissions?: Record<string, string[]>;
mcp_toolsets?: string[] | null;
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
}
import type { ObjectPermission } from "./object_permission_types";
interface ObjectPermissionsViewProps {
objectPermission?: ObjectPermission;
objectPermission?: ObjectPermission | null;
variant?: "card" | "inline";
className?: string;
accessToken?: string | null;

View file

@ -1644,9 +1644,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
/>
</div>
</AccordionBody>
<Form.Item name="duration" hidden initialValue={null}>
<Input />
</Form.Item>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>

View file

@ -0,0 +1,203 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
vi.mock("@/components/ModelSelect/ModelSelect", () => ({
ModelSelect: ({ onChange }: { onChange: (values: string[]) => void }) => (
<button type="button" onClick={() => onChange(["gpt-5.2"])}>
set-models
</button>
),
}));
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
__esModule: true,
default: ({ onChange }: { onChange: (values: string[]) => void }) => (
<button type="button" onClick={() => onChange(["vs-1"])}>
set-vector-stores
</button>
),
}));
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (values: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
}) => (
<button type="button" onClick={() => onChange({ servers: ["srv-1"], accessGroups: [], toolsets: ["ts-1"] })}>
set-mcp
</button>
),
}));
import { OrgCreateDialog } from "./OrgCreateDialog";
const Harness = ({ createOrganization }: { createOrganization: (body: unknown) => Promise<unknown> }) => {
const [open, setOpen] = React.useState(true);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
reopen
</button>
<OrgCreateDialog open={open} onOpenChange={setOpen} accessToken="token" createOrganization={createOrganization} />
</>
);
};
const renderDialog = (overrides?: { createOrganization?: ReturnType<typeof vi.fn> }) => {
const createOrganization = overrides?.createOrganization ?? vi.fn().mockResolvedValue({});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<Harness createOrganization={createOrganization} />
</QueryClientProvider>,
);
return { createOrganization };
};
describe("OrgCreateDialog", () => {
it("blocks submit and shows an error when the name is missing", async () => {
const user = userEvent.setup();
const { createOrganization } = renderDialog();
await user.click(screen.getByRole("button", { name: "Create Organization" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Please input an organization name");
expect(createOrganization).not.toHaveBeenCalled();
});
it("sends only alias and models for a minimal create and closes the dialog", async () => {
const user = userEvent.setup();
const { createOrganization } = renderDialog();
await user.type(screen.getByLabelText("Organization Name"), "new-org");
await user.click(screen.getByRole("button", { name: "Create Organization" }));
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
expect(createOrganization.mock.calls[0][0]).toStrictEqual({ organization_alias: "new-org", models: [] });
await waitFor(() => expect(screen.queryByLabelText("Organization Name")).not.toBeInTheDocument());
});
it("maps selectors and limits into the create body", async () => {
const user = userEvent.setup();
const { createOrganization } = renderDialog();
await user.type(screen.getByLabelText("Organization Name"), "new-org");
await user.click(screen.getByRole("button", { name: "set-models" }));
await user.type(screen.getByLabelText("Tokens per minute Limit (TPM)"), "1000");
await user.click(screen.getByRole("button", { name: "set-vector-stores" }));
await user.click(screen.getByRole("button", { name: "set-mcp" }));
await user.click(screen.getByRole("button", { name: "Create Organization" }));
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
const expectedBody = {
organization_alias: "new-org",
models: ["gpt-5.2"],
tpm_limit: 1000,
object_permission: {
vector_stores: ["vs-1"],
mcp_servers: ["srv-1"],
mcp_toolsets: ["ts-1"],
},
};
expect(createOrganization.mock.calls[0][0]).toStrictEqual(expectedBody);
});
it("blocks submit and shows an error for invalid metadata JSON", async () => {
const user = userEvent.setup();
const { createOrganization } = renderDialog();
await user.type(screen.getByLabelText("Organization Name"), "new-org");
await user.type(screen.getByLabelText("Metadata"), "not json");
await user.click(screen.getByRole("button", { name: "Create Organization" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Metadata must be a valid JSON object");
expect(createOrganization).not.toHaveBeenCalled();
});
it("keeps the dialog open with the entered values when the create fails", async () => {
const user = userEvent.setup();
const { createOrganization } = renderDialog({
createOrganization: vi.fn().mockRejectedValue(new Error("boom")),
});
await user.type(screen.getByLabelText("Organization Name"), "new-org");
await user.click(screen.getByRole("button", { name: "Create Organization" }));
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
expect(screen.getByLabelText("Organization Name")).toHaveValue("new-org");
});
it("resets the form when the dialog is cancelled and reopened", async () => {
const user = userEvent.setup();
renderDialog();
await user.type(screen.getByLabelText("Organization Name"), "abandoned");
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByLabelText("Organization Name")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "reopen" }));
expect(screen.getByLabelText("Organization Name")).toHaveValue("");
});
it("resets the form when the dialog is dismissed with Escape and reopened", async () => {
const user = userEvent.setup();
renderDialog();
await user.type(screen.getByLabelText("Organization Name"), "abandoned");
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByLabelText("Organization Name")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "reopen" }));
expect(screen.getByLabelText("Organization Name")).toHaveValue("");
});
it("cannot be dismissed while a create is pending, then closes once on success", async () => {
const user = userEvent.setup();
let resolveCreate: (value: unknown) => void = () => {};
const createOrganization = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
renderDialog({ createOrganization });
await user.type(screen.getByLabelText("Organization Name"), "new-org");
await user.keyboard("{Enter}");
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
await user.keyboard("{Escape}");
expect(screen.getByLabelText("Organization Name")).toHaveValue("new-org");
resolveCreate({});
await waitFor(() => expect(screen.queryByLabelText("Organization Name")).not.toBeInTheDocument());
});
it("does not fire a second create while one is pending", async () => {
const user = userEvent.setup();
let resolveCreate: (value: unknown) => void = () => {};
const createOrganization = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
renderDialog({ createOrganization });
await user.type(screen.getByLabelText("Organization Name"), "new-org");
await user.keyboard("{Enter}");
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
await user.keyboard("{Enter}");
expect(createOrganization).toHaveBeenCalledTimes(1);
resolveCreate({});
await waitFor(() => expect(screen.queryByLabelText("Organization Name")).not.toBeInTheDocument());
});
});

View file

@ -0,0 +1,186 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
import { BUDGET_DURATION_OPTIONS, NO_RESET } from "../org-settings/OrgSettingsForm";
import { orgSettingsSchema } from "../org-settings/schema";
import { buildOrgCreateBody, emptyOrgFormValues, type OrgCreateBody } from "./mapper";
const defaultCreateOrganization = async (body: OrgCreateBody): Promise<unknown> => {
const { data } = await fetchClient.POST("/organization/new", { body });
return data;
};
interface OrgCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
accessToken: string;
createOrganization?: (body: OrgCreateBody) => Promise<unknown>;
}
export const OrgCreateDialog = ({
open,
onOpenChange,
accessToken,
createOrganization = defaultCreateOrganization,
}: OrgCreateDialogProps) => {
const queryClient = useQueryClient();
const form = useZodForm(orgSettingsSchema, { defaultValues: emptyOrgFormValues });
const closeAndReset = () => {
form.reset(emptyOrgFormValues);
onOpenChange(false);
};
const mutation = useMutation({
mutationFn: (body: OrgCreateBody) => createOrganization(body),
onSuccess: () => {
NotificationsManager.success("Organization created successfully");
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
closeAndReset();
},
onError: (error: unknown) =>
NotificationsManager.fromBackend(error instanceof Error ? error.message : "Failed to create organization"),
});
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && mutation.isPending) return;
if (!nextOpen) {
form.reset(emptyOrgFormValues);
}
onOpenChange(nextOpen);
};
const onSubmit = form.handleSubmit((values) => {
if (mutation.isPending) return;
mutation.mutate(buildOrgCreateBody(values));
});
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Organization</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FieldGroup>
<FormField control={form.control} name="organization_alias" label="Organization Name">
{({ ref, ...field }) => <Input {...field} ref={ref} />}
</FormField>
<FormField control={form.control} name="models" label="Models">
{(field) => (
<ModelSelect
value={field.value}
onChange={field.onChange}
context="organization"
options={{ includeSpecialOptions: true, showAllProxyModelsOverride: true }}
/>
)}
</FormField>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={0.01} min={0} />}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={BUDGET_DURATION_OPTIONS}
value={value === "" ? NO_RESET : value}
onValueChange={(selected) => onChange(selected === NO_RESET ? "" : selected)}
>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUDGET_DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
<FormField control={form.control} name="tpm_limit" label="Tokens per minute Limit (TPM)">
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField control={form.control} name="rpm_limit" label="Requests per minute Limit (RPM)">
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
</FormField>
<FormField
control={form.control}
name="vector_stores"
label="Allowed Vector Stores"
description="Select vector stores this organization can access. Leave empty for access to all vector stores"
>
{(field) => (
<VectorStoreSelector
value={field.value}
onChange={field.onChange}
accessToken={accessToken}
placeholder="Select vector stores (optional)"
/>
)}
</FormField>
<FormField
control={form.control}
name="mcp"
label="Allowed MCP Servers"
description="Select MCP servers, access groups, and toolsets this organization can access. Leave empty for access to all"
>
{(field) => (
<MCPServerSelector
value={field.value}
onChange={field.onChange}
accessToken={accessToken}
placeholder="Select MCP servers and access groups (optional)"
/>
)}
</FormField>
<FormField control={form.control} name="metadata" label="Metadata">
{({ ref, ...field }) => <Textarea {...field} ref={ref} rows={4} />}
</FormField>
</FieldGroup>
<DialogFooter className="mt-6">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create Organization"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { buildOrgCreateBody, emptyOrgFormValues } from "./mapper";
describe("buildOrgCreateBody", () => {
it("sends only alias and models for a minimal form", () => {
expect(buildOrgCreateBody({ ...emptyOrgFormValues, organization_alias: "acme" })).toStrictEqual({
organization_alias: "acme",
models: [],
});
});
it("maps every field when the whole form is filled", () => {
const filledForm = {
organization_alias: "acme",
models: ["gpt-5.2"],
max_budget: "12.5",
budget_duration: "30d",
tpm_limit: "1000",
rpm_limit: "50",
vector_stores: ["vs-1"],
mcp: { servers: ["srv-1"], accessGroups: ["ag-1"], toolsets: ["ts-1"] },
metadata: '{"env": "prod"}',
};
const expectedBody = {
organization_alias: "acme",
models: ["gpt-5.2"],
max_budget: 12.5,
budget_duration: "30d",
tpm_limit: 1000,
rpm_limit: 50,
metadata: { env: "prod" },
object_permission: {
vector_stores: ["vs-1"],
mcp_servers: ["srv-1"],
mcp_access_groups: ["ag-1"],
mcp_toolsets: ["ts-1"],
},
};
expect(buildOrgCreateBody(filledForm)).toStrictEqual(expectedBody);
});
it("includes only the non-empty grant lists in object_permission", () => {
expect(
buildOrgCreateBody({
...emptyOrgFormValues,
organization_alias: "acme",
mcp: { servers: [], accessGroups: [], toolsets: ["ts-1"] },
}).object_permission,
).toStrictEqual({ mcp_toolsets: ["ts-1"] });
});
it("parses metadata into an object instead of sending the raw string", () => {
expect(
buildOrgCreateBody({ ...emptyOrgFormValues, organization_alias: "acme", metadata: '{"a": 1}' }).metadata,
).toStrictEqual({ a: 1 });
});
});

View file

@ -0,0 +1,45 @@
import { z } from "zod/v4";
import type { components } from "@/lib/http/schema";
import type { OrgSettingsFormValues } from "../org-settings/schema";
export type OrgCreateBody = components["schemas"]["NewOrganizationRequest"];
export const emptyOrgFormValues: OrgSettingsFormValues = {
organization_alias: "",
models: [],
max_budget: "",
budget_duration: "",
tpm_limit: "",
rpm_limit: "",
vector_stores: [],
mcp: { servers: [], accessGroups: [], toolsets: [] },
metadata: "",
};
const metadataRecordSchema = z.record(z.string(), z.unknown());
const objectPermissionFromValues = (values: OrgSettingsFormValues): OrgCreateBody["object_permission"] => {
const grants = {
...(values.vector_stores.length > 0 && { vector_stores: values.vector_stores }),
...(values.mcp.servers.length > 0 && { mcp_servers: values.mcp.servers }),
...(values.mcp.accessGroups.length > 0 && { mcp_access_groups: values.mcp.accessGroups }),
...(values.mcp.toolsets.length > 0 && { mcp_toolsets: values.mcp.toolsets }),
};
return Object.keys(grants).length > 0 ? grants : undefined;
};
export const buildOrgCreateBody = (values: OrgSettingsFormValues): OrgCreateBody => {
const objectPermission = objectPermissionFromValues(values);
return {
organization_alias: values.organization_alias,
models: values.models,
...(values.max_budget.trim() !== "" && { max_budget: Number(values.max_budget) }),
...(values.tpm_limit.trim() !== "" && { tpm_limit: Number(values.tpm_limit) }),
...(values.rpm_limit.trim() !== "" && { rpm_limit: Number(values.rpm_limit) }),
...(values.budget_duration !== "" && { budget_duration: values.budget_duration }),
...(values.metadata.trim() !== "" && { metadata: metadataRecordSchema.parse(JSON.parse(values.metadata)) }),
...(objectPermission !== undefined && { object_permission: objectPermission }),
};
};

View file

@ -22,9 +22,9 @@ import { fetchClient } from "@/lib/http/api";
import { buildOrgPatch, orgToForm, type OrgPatchBody } from "./mapper";
import { orgSettingsSchema } from "./schema";
const NO_RESET = "never";
export const NO_RESET = "never";
const BUDGET_DURATION_OPTIONS = [
export const BUDGET_DURATION_OPTIONS = [
{ value: NO_RESET, label: "No reset" },
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
@ -102,6 +102,7 @@ export const OrgSettingsForm = ({
<FormField control={form.control} name="budget_duration" label="Reset Budget">
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={BUDGET_DURATION_OPTIONS}
value={value === "" ? NO_RESET : value}
onValueChange={(selected) => onChange(selected === NO_RESET ? "" : selected)}
>

View file

@ -17,6 +17,7 @@ import {
import { useGuardrails, GuardrailListItem } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import type { ObjectPermission } from "@/components/object_permission_types";
import { isProxyAdminRole } from "@/utils/roles";
import {
EditOutlined,
@ -118,17 +119,7 @@ export interface TeamData {
router_settings?: Record<string, any>;
guardrails?: string[];
policies?: string[];
object_permission?: {
object_permission_id: string;
mcp_servers: string[];
mcp_access_groups?: string[];
mcp_tool_permissions?: Record<string, string[]>;
mcp_toolsets?: string[];
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
};
object_permission?: ObjectPermission | null;
team_member_budget_table: {
max_budget: number;
budget_duration: string;

View file

@ -860,9 +860,6 @@ export function KeyEditView({
neverExpire={neverExpire}
onNeverExpireChange={setNeverExpire}
/>
<Form.Item name="duration" hidden initialValue="">
<Input />
</Form.Item>
</div>
{/* Hidden form field for token */}