mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/litellm-logs-ui-lag-0ca4b8
This commit is contained in:
commit
12bd6b5e5b
22 changed files with 752 additions and 543 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
58
tests/e2e/mcp/test_mcp_access_group_e2e.py
Normal file
58
tests/e2e/mcp/test_mcp_access_group_e2e.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type ObjectPermission = Partial<components["schemas"]["LiteLLM_ObjectPermissionTable"]>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
};
|
||||
};
|
||||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue