mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): migrate prompt, UI access, plugin and MCP filter forms to react-hook-form and shadcn (#37297)
* refactor(ui): migrate prompt, UI access, plugin and MCP filter forms to react-hook-form and shadcn
Moves four more admin dashboard forms off antd Form onto react-hook-form with the
shared shadcn form kit, keeping the submitted payload byte-identical in every case.
Each form was pinned with a characterization test proven green against the antd
original before any production code changed, then re-run unedited afterwards.
Behaviours that needed reproducing by hand rather than falling out of the port:
- antd onFinish reports only mounted fields, so UIAccessControlForm blanks a seeded
but hidden restricted_sso_group at submit time instead of sending it
- antd InputNumber returns null on empty and clamps on blur, so MCPSemanticFilterSettings
keeps top_k as null when cleared and clamps to [1, 100] rather than sending "" or NaN
- PluginSettings seeds plugin_key blank on edit so an untouched save preserves the
stored credential instead of overwriting it with the redacted placeholder
- antd's url rule and zod's .url() disagree in both directions, so the async-validator
pattern is ported verbatim to avoid silently changing which URLs are accepted
Forms with no onFinish keep preventDefault so no Enter-to-submit is introduced, and
the plugin key regains a reveal toggle built on InputGroup.
* chore(ui): prune stale eslint suppressions left by concurrent form-migration PRs
* Revert "chore(ui): prune stale eslint suppressions left by concurrent form-migration PRs"
This reverts commit e36bcc5862.
* fix(ui): keep the embedding model unclearable, matching the antd Select
The antd Select for embedding_model had no allowClear, so an admin could never
empty it. SearchSelect renders a clear button whenever a value is set and emits
an empty string, so the migration silently added a way to persist an empty
embedding_model and break semantic filtering.
Adds an opt-out to SearchSelect that defaults to the current behaviour, leaving
the other twenty callers unaffected, and opts this one field out. Pinned with a
test that fails when the opt-out is removed.
This commit is contained in:
parent
d6fe9712fa
commit
aa90828811
11 changed files with 1279 additions and 388 deletions
|
|
@ -1251,7 +1251,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/prompts/_components/index.tsx": {
|
||||
|
|
@ -1838,11 +1838,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/UIAccessControlForm.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/UsagePage/components/EntityUsage/TopKeyView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { toast } from "@/lib/toast";
|
||||
import { convertPromptFileToJson, createPromptCall } from "@/components/networking";
|
||||
|
||||
import AddPromptForm from "./add_prompt_form";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
convertPromptFileToJson: vi.fn(),
|
||||
createPromptCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: { success: vi.fn(), fromError: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockConvert = vi.mocked(convertPromptFileToJson);
|
||||
const mockCreate = vi.mocked(createPromptCall);
|
||||
const mockFromBackend = vi.mocked(toast.fromError);
|
||||
const mockSuccess = vi.mocked(toast.success);
|
||||
|
||||
const PROMPT_ID_PLACEHOLDER = "Enter unique prompt ID (e.g., my_prompt_id)";
|
||||
|
||||
const CONVERTED_JSON = { model: "gpt-4o", messages: [{ role: "user", content: "hi {{name}}" }] };
|
||||
|
||||
const renderForm = () => {
|
||||
const onClose = vi.fn();
|
||||
const onSuccess = vi.fn();
|
||||
render(<AddPromptForm visible onClose={onClose} accessToken="sk-test" onSuccess={onSuccess} />);
|
||||
return { onClose, onSuccess };
|
||||
};
|
||||
|
||||
const typePromptId = (value: string) =>
|
||||
fireEvent.change(screen.getByPlaceholderText(PROMPT_ID_PLACEHOLDER), { target: { value } });
|
||||
|
||||
const attachPromptFile = async (file: File) => {
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
await screen.findByText(`Selected: ${file.name}`);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Prompt" }));
|
||||
});
|
||||
};
|
||||
|
||||
describe("AddPromptForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConvert.mockResolvedValue({ prompt_id: "converted_prompt_id", json_data: CONVERTED_JSON });
|
||||
mockCreate.mockResolvedValue({ status: "success" });
|
||||
});
|
||||
|
||||
it("sends the converted upload as the exact create-prompt payload, then closes and refreshes", async () => {
|
||||
const { onClose, onSuccess } = renderForm();
|
||||
const file = new File(["model: gpt-4o"], "greeting.prompt", { type: "text/plain" });
|
||||
|
||||
typePromptId("my_prompt_id");
|
||||
await attachPromptFile(file);
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith("sk-test", {
|
||||
prompt_id: "my_prompt_id",
|
||||
litellm_params: {
|
||||
prompt_integration: "dotprompt",
|
||||
prompt_id: "converted_prompt_id",
|
||||
prompt_data: CONVERTED_JSON,
|
||||
},
|
||||
prompt_info: {
|
||||
prompt_type: "db",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(mockConvert).toHaveBeenCalledWith("sk-test", file);
|
||||
expect(mockSuccess).toHaveBeenCalledWith("Prompt created successfully!");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses to submit without an uploaded file", async () => {
|
||||
renderForm();
|
||||
|
||||
typePromptId("my_prompt_id");
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFromBackend).toHaveBeenCalledWith("Please upload a .prompt file");
|
||||
});
|
||||
expect(mockConvert).not.toHaveBeenCalled();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submission and reports a missing prompt ID", async () => {
|
||||
renderForm();
|
||||
|
||||
await submit();
|
||||
|
||||
expect(await screen.findByText("Please enter a prompt ID")).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submission and reports a prompt ID with unsupported characters", async () => {
|
||||
renderForm();
|
||||
const file = new File(["model: gpt-4o"], "greeting.prompt", { type: "text/plain" });
|
||||
|
||||
typePromptId("my prompt!");
|
||||
await attachPromptFile(file);
|
||||
await submit();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Prompt ID can only contain letters, numbers, underscores, and hyphens"),
|
||||
).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
import React, { useState } from "react";
|
||||
import { Modal, Form, Select, Upload, Button, Divider } from "antd";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { UploadOutlined } from "@ant-design/icons";
|
||||
import { Modal, Upload } from "antd";
|
||||
import type { UploadFile, UploadProps } from "antd";
|
||||
import { Upload as UploadIcon } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
import { convertPromptFileToJson, createPromptCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const { Option } = Select;
|
||||
import { Field, FieldDescription, FieldGroup, FieldSeparator, FieldTitle } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
interface AddPromptFormProps {
|
||||
visible: boolean;
|
||||
|
|
@ -15,76 +20,107 @@ interface AddPromptFormProps {
|
|||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface CreatePromptRequest {
|
||||
prompt_id: string;
|
||||
litellm_params: {
|
||||
prompt_integration: string;
|
||||
prompt_id: string;
|
||||
prompt_data: unknown;
|
||||
};
|
||||
prompt_info: {
|
||||
prompt_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PROMPT_INTEGRATION_OPTIONS = [{ label: "dotprompt", value: "dotprompt" }];
|
||||
|
||||
const addPromptSchema = z.object({
|
||||
prompt_id: z
|
||||
.string()
|
||||
.min(1, "Please enter a prompt ID")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Prompt ID can only contain letters, numbers, underscores, and hyphens"),
|
||||
prompt_integration: z.string(),
|
||||
});
|
||||
|
||||
type AddPromptFormValues = z.infer<typeof addPromptSchema>;
|
||||
|
||||
const EMPTY_VALUES: AddPromptFormValues = { prompt_id: "", prompt_integration: "dotprompt" };
|
||||
|
||||
const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useZodForm(addPromptSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [promptIntegration, setPromptIntegration] = useState<string>("dotprompt");
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(EMPTY_VALUES);
|
||||
setFileList([]);
|
||||
setPromptIntegration("dotprompt");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleIntegrationChange = (selected: string | null) => {
|
||||
if (selected === null) return;
|
||||
form.setValue("prompt_integration", selected);
|
||||
setPromptIntegration(selected);
|
||||
};
|
||||
|
||||
const convertUploadedFile = async (token: string, promptId: string): Promise<CreatePromptRequest | null> => {
|
||||
const file = fileList[0].originFileObj as File;
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const conversionResult = await convertPromptFileToJson(token, file);
|
||||
|
||||
if (!accessToken) {
|
||||
toast.fromError("Access token is required");
|
||||
return;
|
||||
}
|
||||
return {
|
||||
prompt_id: promptId,
|
||||
litellm_params: {
|
||||
prompt_integration: "dotprompt",
|
||||
prompt_id: conversionResult.prompt_id,
|
||||
prompt_data: conversionResult.json_data,
|
||||
},
|
||||
prompt_info: {
|
||||
prompt_type: "db",
|
||||
},
|
||||
};
|
||||
} catch (conversionError) {
|
||||
console.error("Error converting prompt file:", conversionError);
|
||||
toast.fromError("Failed to convert prompt file to JSON");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (promptIntegration === "dotprompt" && fileList.length === 0) {
|
||||
toast.fromError("Please upload a .prompt file");
|
||||
return;
|
||||
}
|
||||
const handleSubmit = async (values: AddPromptFormValues) => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("Access token is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const isDotprompt = promptIntegration === "dotprompt";
|
||||
|
||||
let promptData: any = {};
|
||||
if (isDotprompt && fileList.length === 0) {
|
||||
toast.fromError("Please upload a .prompt file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (promptIntegration === "dotprompt" && fileList.length > 0) {
|
||||
// Convert the uploaded file to JSON
|
||||
const file = fileList[0].originFileObj as File;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const conversionResult = await convertPromptFileToJson(accessToken, file);
|
||||
const promptData: CreatePromptRequest | Record<string, never> | null = isDotprompt
|
||||
? await convertUploadedFile(accessToken, values.prompt_id)
|
||||
: {};
|
||||
|
||||
// Prepare prompt data for creation
|
||||
promptData = {
|
||||
prompt_id: values.prompt_id,
|
||||
litellm_params: {
|
||||
prompt_integration: "dotprompt",
|
||||
prompt_id: conversionResult.prompt_id,
|
||||
prompt_data: conversionResult.json_data,
|
||||
},
|
||||
prompt_info: {
|
||||
prompt_type: "db",
|
||||
},
|
||||
};
|
||||
} catch (conversionError) {
|
||||
console.error("Error converting prompt file:", conversionError);
|
||||
toast.fromError("Failed to convert prompt file to JSON");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (promptData === null) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the prompt
|
||||
try {
|
||||
await createPromptCall(accessToken, promptData);
|
||||
toast.success("Prompt created successfully!");
|
||||
handleCancel();
|
||||
onSuccess();
|
||||
} catch (createError) {
|
||||
console.error("Error creating prompt:", createError);
|
||||
toast.fromError("Failed to create prompt");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Form validation error:", error);
|
||||
try {
|
||||
await createPromptCall(accessToken, promptData);
|
||||
toast.success("Prompt created successfully!");
|
||||
handleCancel();
|
||||
onSuccess();
|
||||
} catch (createError) {
|
||||
console.error("Error creating prompt:", createError);
|
||||
toast.fromError("Failed to create prompt");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -113,48 +149,61 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCancel}>
|
||||
<Button key="cancel" type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" loading={loading} onClick={handleSubmit}>
|
||||
<Button key="submit" type="button" disabled={loading} onClick={() => void form.handleSubmit(handleSubmit)()}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Create Prompt
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
label="Prompt ID"
|
||||
name="prompt_id"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter a prompt ID" },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+$/,
|
||||
message: "Prompt ID can only contain letters, numbers, underscores, and hyphens",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput placeholder="Enter unique prompt ID (e.g., my_prompt_id)" />
|
||||
</Form.Item>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="prompt_id" label="Prompt ID">
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="Enter unique prompt ID (e.g., my_prompt_id)" />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Form.Item label="Prompt Integration" name="prompt_integration" initialValue="dotprompt">
|
||||
<Select value={promptIntegration} onChange={setPromptIntegration}>
|
||||
<Option value="dotprompt">dotprompt</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<FormField control={form.control} name="prompt_integration" label="Prompt Integration">
|
||||
{({ id, value, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select items={PROMPT_INTEGRATION_OPTIONS} value={value} onValueChange={handleIntegrationChange}>
|
||||
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROMPT_INTEGRATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{promptIntegration === "dotprompt" && (
|
||||
<>
|
||||
<Divider />
|
||||
<Form.Item label="Prompt File" extra="Upload a .prompt file that follows the Dotprompt specification">
|
||||
<Upload {...uploadProps}>
|
||||
<Button icon={<UploadOutlined />}>Select .prompt File</Button>
|
||||
</Upload>
|
||||
{fileList.length > 0 && <div className="mt-2 text-sm text-gray-600">Selected: {fileList[0].name}</div>}
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
{promptIntegration === "dotprompt" && (
|
||||
<>
|
||||
<FieldSeparator />
|
||||
<Field>
|
||||
<FieldTitle>Prompt File</FieldTitle>
|
||||
<Upload {...uploadProps}>
|
||||
<Button type="button" variant="outline">
|
||||
<UploadIcon />
|
||||
Select .prompt File
|
||||
</Button>
|
||||
</Upload>
|
||||
{fileList.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">Selected: {fileList[0].name}</div>
|
||||
)}
|
||||
<FieldDescription>Upload a .prompt file that follows the Dotprompt specification</FieldDescription>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MCPSemanticFilterSettings from "./MCPSemanticFilterSettings";
|
||||
import { useMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings";
|
||||
import { useUpdateMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings";
|
||||
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings", () => ({
|
||||
useMCPSemanticFilterSettings: vi.fn(),
|
||||
|
|
@ -43,13 +44,61 @@ const defaultSettingsData = {
|
|||
},
|
||||
};
|
||||
|
||||
const AVAILABLE_MODELS = [
|
||||
{ model_group: "text-embedding-3-large", mode: "embedding" },
|
||||
{ model_group: "gpt-4o", mode: "chat" },
|
||||
];
|
||||
|
||||
const SWITCH_ONLY_PAYLOAD = {
|
||||
enabled: true,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: 10,
|
||||
similarity_threshold: 0.3,
|
||||
};
|
||||
|
||||
const DEFAULTED_PAYLOAD = {
|
||||
enabled: false,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: 7,
|
||||
similarity_threshold: 0.3,
|
||||
};
|
||||
|
||||
const FULLY_EDITED_PAYLOAD = {
|
||||
enabled: true,
|
||||
embedding_model: "text-embedding-3-large",
|
||||
top_k: 25,
|
||||
similarity_threshold: 0.35,
|
||||
};
|
||||
|
||||
const CLEARED_TOP_K_PAYLOAD = {
|
||||
enabled: false,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: null,
|
||||
similarity_threshold: 0.3,
|
||||
};
|
||||
|
||||
const CLAMPED_MAX_PAYLOAD = {
|
||||
enabled: false,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: 100,
|
||||
similarity_threshold: 0.3,
|
||||
};
|
||||
|
||||
const CLAMPED_MIN_PAYLOAD = {
|
||||
enabled: false,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: 1,
|
||||
similarity_threshold: 0.3,
|
||||
};
|
||||
|
||||
// Helper that renders the component and flushes the fetchAvailableModels effect
|
||||
async function renderSettings(props: React.ComponentProps<typeof MCPSemanticFilterSettings>) {
|
||||
render(<MCPSemanticFilterSettings {...props} />);
|
||||
const result = render(<MCPSemanticFilterSettings {...props} />);
|
||||
if (props.accessToken) {
|
||||
// Let the async fetchAvailableModels effect settle to avoid act() warnings
|
||||
await act(async () => {});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("MCPSemanticFilterSettings", () => {
|
||||
|
|
@ -156,4 +205,101 @@ describe("MCPSemanticFilterSettings", () => {
|
|||
expect(screen.getByText("Could not update settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed to update settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should send the default values as the payload when only the switch is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(SWITCH_ONLY_PAYLOAD, expect.anything());
|
||||
});
|
||||
|
||||
it("should fall back to the hardcoded defaults when the backend returns no values", async () => {
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: { field_schema: defaultSettingsData.field_schema, values: {} },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
const topK = screen.getByRole("spinbutton");
|
||||
await user.clear(topK);
|
||||
await user.type(topK, "7");
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(DEFAULTED_PAYLOAD, expect.anything());
|
||||
});
|
||||
|
||||
it("should send every edited field in the payload", async () => {
|
||||
vi.mocked(fetchAvailableModels).mockResolvedValueOnce(AVAILABLE_MODELS);
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
|
||||
const topK = screen.getByRole("spinbutton");
|
||||
await user.clear(topK);
|
||||
await user.type(topK, "25");
|
||||
|
||||
const slider = screen.getByRole("slider", { hidden: true });
|
||||
fireEvent.keyDown(slider, { key: "ArrowRight", keyCode: 39, which: 39 });
|
||||
|
||||
const embeddingModel = screen.getByRole("combobox");
|
||||
await user.click(embeddingModel);
|
||||
await user.clear(embeddingModel);
|
||||
await user.type(embeddingModel, "large");
|
||||
fireEvent.keyDown(embeddingModel, { key: "ArrowDown", keyCode: 40, which: 40 });
|
||||
fireEvent.keyDown(embeddingModel, { key: "Enter", keyCode: 13, which: 13 });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(FULLY_EDITED_PAYLOAD, expect.anything());
|
||||
});
|
||||
|
||||
it("should send top_k as null when the field is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
const topK = screen.getByRole("spinbutton");
|
||||
await user.clear(topK);
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(CLEARED_TOP_K_PAYLOAD, expect.anything());
|
||||
});
|
||||
|
||||
it("should clamp top_k above the maximum back into range on blur", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
const topK = screen.getByRole("spinbutton");
|
||||
await user.clear(topK);
|
||||
await user.type(topK, "500");
|
||||
await user.tab();
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(CLAMPED_MAX_PAYLOAD, expect.anything());
|
||||
});
|
||||
|
||||
it("should clamp top_k below the minimum back into range on blur", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
const topK = screen.getByRole("spinbutton");
|
||||
await user.clear(topK);
|
||||
await user.type(topK, "0");
|
||||
await user.tab();
|
||||
await user.click(screen.getByRole("button", { name: /save settings/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(CLAMPED_MIN_PAYLOAD, expect.anything());
|
||||
});
|
||||
it("offers no way to clear the embedding model, as the antd Select had no allowClear", async () => {
|
||||
const { container } = await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("text-embedding-3-small");
|
||||
expect(container.querySelector('[data-slot="combobox-clear"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,25 +3,21 @@
|
|||
import { useMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings";
|
||||
import { useUpdateMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
Slider,
|
||||
Space,
|
||||
Switch,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import { QuestionCircleOutlined, CheckCircleOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { Alert, Card, Col, Row, Skeleton } from "antd";
|
||||
import { CheckCircleOutlined } from "@ant-design/icons";
|
||||
import { CircleHelp, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import MCPSemanticFilterTestPanel from "./MCPSemanticFilterTestPanel";
|
||||
import { getCurlCommand, runSemanticFilterTest, TestResult } from "./semanticFilterTestUtils";
|
||||
|
||||
|
|
@ -29,6 +25,60 @@ interface MCPSemanticFilterSettingsProps {
|
|||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface MCPSemanticFilterStoredValues {
|
||||
enabled?: boolean;
|
||||
embedding_model?: string;
|
||||
top_k?: number;
|
||||
similarity_threshold?: number;
|
||||
}
|
||||
|
||||
interface MCPSemanticFilterFieldSchema {
|
||||
properties?: { enabled?: { description?: string } };
|
||||
}
|
||||
|
||||
interface MCPSemanticFilterFormValues {
|
||||
enabled: boolean;
|
||||
embedding_model: string;
|
||||
top_k: number | null;
|
||||
similarity_threshold: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM_VALUES: MCPSemanticFilterFormValues = {
|
||||
enabled: false,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: 10,
|
||||
similarity_threshold: 0.3,
|
||||
};
|
||||
|
||||
const NO_STORED_VALUES: MCPSemanticFilterStoredValues = {};
|
||||
|
||||
const TOP_K_MIN = 1;
|
||||
const TOP_K_MAX = 100;
|
||||
|
||||
const SIMILARITY_THRESHOLD_MARKS = [
|
||||
{ value: 0, label: "0.0" },
|
||||
{ value: 0.3, label: "0.3" },
|
||||
{ value: 0.5, label: "0.5" },
|
||||
{ value: 0.7, label: "0.7" },
|
||||
{ value: 1, label: "1.0" },
|
||||
];
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const clampTopK = (value: number | null): number | null =>
|
||||
value === null ? null : Math.min(TOP_K_MAX, Math.max(TOP_K_MIN, value));
|
||||
|
||||
const parseTopK = (raw: string, rawAsNumber: number): number | null =>
|
||||
raw === "" || Number.isNaN(rawAsNumber) ? null : rawAsNumber;
|
||||
|
||||
export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFilterSettingsProps) {
|
||||
const { data, isLoading, isError, error } = useMCPSemanticFilterSettings();
|
||||
const {
|
||||
|
|
@ -36,7 +86,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
isPending: isUpdating,
|
||||
error: updateError,
|
||||
} = useUpdateMCPSemanticFilterSettings(accessToken || "");
|
||||
const [form] = Form.useForm();
|
||||
const form = useForm<MCPSemanticFilterFormValues>({ defaultValues: DEFAULT_FORM_VALUES });
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [embeddingModels, setEmbeddingModels] = useState<ModelGroup[]>([]);
|
||||
|
|
@ -49,8 +99,8 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
const schema = data?.field_schema;
|
||||
const values = data?.values ?? {};
|
||||
const schema: MCPSemanticFilterFieldSchema | undefined = data?.field_schema;
|
||||
const values: MCPSemanticFilterStoredValues = data?.values ?? NO_STORED_VALUES;
|
||||
|
||||
useEffect(() => {
|
||||
const loadEmbeddingModels = async () => {
|
||||
|
|
@ -72,33 +122,33 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
|
||||
useEffect(() => {
|
||||
if (values) {
|
||||
form.setFieldsValue({
|
||||
enabled: values.enabled ?? false,
|
||||
embedding_model: values.embedding_model ?? "text-embedding-3-small",
|
||||
top_k: values.top_k ?? 10,
|
||||
similarity_threshold: values.similarity_threshold ?? 0.3,
|
||||
form.reset({
|
||||
enabled: values.enabled ?? DEFAULT_FORM_VALUES.enabled,
|
||||
embedding_model: values.embedding_model ?? DEFAULT_FORM_VALUES.embedding_model,
|
||||
top_k: values.top_k ?? DEFAULT_FORM_VALUES.top_k,
|
||||
similarity_threshold: values.similarity_threshold ?? DEFAULT_FORM_VALUES.similarity_threshold,
|
||||
});
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [values, form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const formValues = await form.validateFields();
|
||||
updateSettings(formValues, {
|
||||
onSuccess: () => {
|
||||
setIsDirty(false);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
toast.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Form validation failed:", error);
|
||||
}
|
||||
const commitChange = <TValue,>(onChange: (value: TValue) => void, value: TValue) => {
|
||||
onChange(value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = (formValues: MCPSemanticFilterFormValues) => {
|
||||
updateSettings(formValues, {
|
||||
onSuccess: () => {
|
||||
setIsDirty(false);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
toast.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
|
|
@ -117,7 +167,9 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
};
|
||||
|
||||
if (!accessToken) {
|
||||
return <div className="p-6 text-center text-gray-500">Please log in to configure semantic filter settings.</div>;
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">Please log in to configure semantic filter settings.</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -164,113 +216,132 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi
|
|||
<Row gutter={24}>
|
||||
{/* Left Column - Settings */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
disabled={isUpdating}
|
||||
onValuesChange={() => {
|
||||
setIsDirty(true);
|
||||
}}
|
||||
>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
name="enabled"
|
||||
label={
|
||||
<Space>
|
||||
<Typography.Text strong>Enable Semantic Filtering</Typography.Text>
|
||||
<Tooltip title="When enabled, only the most relevant MCP tools will be included in requests based on semantic similarity">
|
||||
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch disabled={isUpdating} />
|
||||
</Form.Item>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
label={labelWithHint(
|
||||
"Enable Semantic Filtering",
|
||||
"When enabled, only the most relevant MCP tools will be included in requests based on semantic similarity",
|
||||
)}
|
||||
description={schema?.properties?.enabled?.description}
|
||||
>
|
||||
{({ value, onChange, onBlur, id }) => (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => commitChange(onChange, checked)}
|
||||
onBlur={onBlur}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</Card>
|
||||
|
||||
<Typography.Text type="secondary" style={{ display: "block", marginTop: -16, marginBottom: 16 }}>
|
||||
{schema?.properties?.enabled?.description}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
<Card title="Configuration" style={{ marginBottom: 16 }}>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="embedding_model"
|
||||
label={labelWithHint(
|
||||
"Embedding Model",
|
||||
"The model used to generate embeddings for semantic matching",
|
||||
)}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
options={embeddingModels.map((model) => ({
|
||||
label: model.model_group,
|
||||
value: model.model_group,
|
||||
}))}
|
||||
value={value}
|
||||
onValueChange={(selected) => commitChange(onChange, selected)}
|
||||
allowClear={false}
|
||||
placeholder={loadingModels ? "Loading models..." : "Select embedding model"}
|
||||
emptyText={loadingModels ? "Loading..." : "No embedding models available"}
|
||||
disabled={isUpdating || loadingModels}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Card title="Configuration" style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
name="embedding_model"
|
||||
label={
|
||||
<Space>
|
||||
<Typography.Text strong>Embedding Model</Typography.Text>
|
||||
<Tooltip title="The model used to generate embeddings for semantic matching">
|
||||
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
options={embeddingModels.map((model) => ({
|
||||
label: model.model_group,
|
||||
value: model.model_group,
|
||||
}))}
|
||||
placeholder={loadingModels ? "Loading models..." : "Select embedding model"}
|
||||
showSearch
|
||||
disabled={isUpdating || loadingModels}
|
||||
loading={loadingModels}
|
||||
notFoundContent={loadingModels ? "Loading..." : "No embedding models available"}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="top_k"
|
||||
label={labelWithHint("Top K Results", "Maximum number of tools to return after filtering")}
|
||||
>
|
||||
{({ ref, value, onChange, onBlur, id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
ref={ref}
|
||||
type="number"
|
||||
min={TOP_K_MIN}
|
||||
max={TOP_K_MAX}
|
||||
value={value ?? ""}
|
||||
onChange={(event) =>
|
||||
commitChange(onChange, parseTopK(event.target.value, event.target.valueAsNumber))
|
||||
}
|
||||
onBlur={() => {
|
||||
onChange(clampTopK(value));
|
||||
onBlur();
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
name="top_k"
|
||||
label={
|
||||
<Space>
|
||||
<Typography.Text strong>Top K Results</Typography.Text>
|
||||
<Tooltip title="Maximum number of tools to return after filtering">
|
||||
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<InputNumber min={1} max={100} style={{ width: "100%" }} disabled={isUpdating} />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="similarity_threshold"
|
||||
label={labelWithHint(
|
||||
"Similarity Threshold",
|
||||
"Minimum similarity score (0-1) for a tool to be included",
|
||||
)}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<div className="w-full">
|
||||
<Slider
|
||||
id={id}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={[value]}
|
||||
onValueChange={(next) => commitChange(onChange, Array.isArray(next) ? next[0] : next)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<div className="relative mt-2 h-4 text-xs text-muted-foreground">
|
||||
{SIMILARITY_THRESHOLD_MARKS.map((mark) => (
|
||||
<span
|
||||
key={mark.value}
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${mark.value * 100}%` }}
|
||||
>
|
||||
{mark.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</Card>
|
||||
|
||||
<Form.Item
|
||||
name="similarity_threshold"
|
||||
label={
|
||||
<Space>
|
||||
<Typography.Text strong>Similarity Threshold</Typography.Text>
|
||||
<Tooltip title="Minimum similarity score (0-1) for a tool to be included">
|
||||
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
marks={{
|
||||
0: "0.0",
|
||||
0.3: "0.3",
|
||||
0.5: "0.5",
|
||||
0.7: "0.7",
|
||||
1: "1.0",
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={isUpdating}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void form.handleSubmit(handleSave)()}
|
||||
disabled={!isDirty || isUpdating}
|
||||
>
|
||||
{isUpdating ? <UiLoadingSpinner className="size-4" /> : <Save />}
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Col>
|
||||
|
||||
{/* Right Column - Test Configuration */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import PluginSettings from "./PluginSettings";
|
||||
|
||||
const { getConfigFieldSettingMock, updateConfigFieldSettingMock } = vi.hoisted(() => ({
|
||||
getConfigFieldSettingMock: vi.fn(),
|
||||
updateConfigFieldSettingMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getConfigFieldSetting: getConfigFieldSettingMock,
|
||||
updateConfigFieldSetting: updateConfigFieldSettingMock,
|
||||
}));
|
||||
|
||||
const REDACTED_PLUGIN = {
|
||||
name: "alpha",
|
||||
display_name: "Alpha",
|
||||
url: "https://alpha.example.com",
|
||||
plugin_key: "***",
|
||||
};
|
||||
|
||||
const savedPayload = () => updateConfigFieldSettingMock.mock.calls[0];
|
||||
|
||||
describe("PluginSettings config payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateConfigFieldSettingMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("sends a new plugin with no plugin_key when the key field is left blank", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigFieldSettingMock.mockResolvedValue({ field_value: [] });
|
||||
render(<PluginSettings />);
|
||||
expect(await screen.findAllByText("No data")).not.toHaveLength(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add plugin/i }));
|
||||
await user.type(await screen.findByLabelText(/Name \(identifier\)/), "beta");
|
||||
await user.type(screen.getByLabelText(/Display Name/), "Beta");
|
||||
await user.type(screen.getByLabelText(/^URL/), "https://beta.example.com");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(updateConfigFieldSettingMock).toHaveBeenCalledTimes(1));
|
||||
expect(savedPayload()).toStrictEqual([
|
||||
"123",
|
||||
"plugins",
|
||||
[
|
||||
{
|
||||
name: "beta",
|
||||
display_name: "Beta",
|
||||
url: "https://beta.example.com",
|
||||
plugin_key: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds the key field blank on edit and sends a blank key when it is left untouched", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigFieldSettingMock.mockResolvedValue({ field_value: [REDACTED_PLUGIN] });
|
||||
render(<PluginSettings />);
|
||||
expect(await screen.findByText("Alpha")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "edit" }));
|
||||
expect(await screen.findByLabelText(/Plugin Key/)).toHaveValue("");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(updateConfigFieldSettingMock).toHaveBeenCalledTimes(1));
|
||||
expect(savedPayload()).toStrictEqual([
|
||||
"123",
|
||||
"plugins",
|
||||
[
|
||||
{
|
||||
name: "alpha",
|
||||
display_name: "Alpha",
|
||||
url: "https://alpha.example.com",
|
||||
plugin_key: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the typed key on edit when the key field is filled in", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigFieldSettingMock.mockResolvedValue({ field_value: [REDACTED_PLUGIN] });
|
||||
render(<PluginSettings />);
|
||||
expect(await screen.findByText("Alpha")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "edit" }));
|
||||
await user.type(await screen.findByLabelText(/Plugin Key/), "sk-brand-new");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(updateConfigFieldSettingMock).toHaveBeenCalledTimes(1));
|
||||
expect(savedPayload()).toStrictEqual([
|
||||
"123",
|
||||
"plugins",
|
||||
[
|
||||
{
|
||||
name: "alpha",
|
||||
display_name: "Alpha",
|
||||
url: "https://alpha.example.com",
|
||||
plugin_key: "sk-brand-new",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginSettings plugin key reveal (post-migration shadcn affordance)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getConfigFieldSettingMock.mockResolvedValue({ field_value: [REDACTED_PLUGIN] });
|
||||
});
|
||||
|
||||
it("flips the key field between hidden and revealed and relabels the toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PluginSettings />);
|
||||
expect(await screen.findByText("Alpha")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "edit" }));
|
||||
const keyInput = await screen.findByLabelText(/Plugin Key/);
|
||||
expect(keyInput).toHaveAttribute("type", "password");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show plugin key" }));
|
||||
expect(keyInput).toHaveAttribute("type", "text");
|
||||
expect(screen.queryByRole("button", { name: "Show plugin key" })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Hide plugin key" }));
|
||||
expect(keyInput).toHaveAttribute("type", "password");
|
||||
expect(screen.queryByRole("button", { name: "Hide plugin key" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Card, Form, Input, Modal, Space, Table, Typography } from "antd";
|
||||
import { Button, Card, Modal, Space, Table, Typography } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { getConfigFieldSetting, updateConfigFieldSetting } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { pluginSchema, type PluginFormValues } from "./schema";
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
|
|
@ -15,6 +22,8 @@ interface Plugin {
|
|||
plugin_key?: string;
|
||||
}
|
||||
|
||||
const BLANK_PLUGIN: PluginFormValues = { name: "", display_name: "", url: "", plugin_key: undefined };
|
||||
|
||||
export default function PluginSettings() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||
|
|
@ -22,7 +31,8 @@ export default function PluginSettings() {
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [form] = Form.useForm<Plugin>();
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const form = useZodForm(pluginSchema, { defaultValues: BLANK_PLUGIN });
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
|
|
@ -48,15 +58,17 @@ export default function PluginSettings() {
|
|||
|
||||
const openAdd = () => {
|
||||
setEditingIndex(null);
|
||||
form.resetFields();
|
||||
setKeyVisible(false);
|
||||
form.reset(BLANK_PLUGIN);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (idx: number) => {
|
||||
setEditingIndex(idx);
|
||||
setKeyVisible(false);
|
||||
// plugin_key arrives redacted ("***"); start it blank so an untouched save
|
||||
// keeps the stored credential instead of overwriting it with the placeholder.
|
||||
form.setFieldsValue({ ...plugins[idx], plugin_key: "" });
|
||||
form.reset({ ...plugins[idx], plugin_key: "" });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -65,8 +77,7 @@ export default function PluginSettings() {
|
|||
save(updated);
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
const values = await form.validateFields();
|
||||
const handleOk = async (values: PluginFormValues) => {
|
||||
const updated =
|
||||
editingIndex !== null ? plugins.map((p, i) => (i === editingIndex ? values : p)) : [...plugins, values];
|
||||
await save(updated);
|
||||
|
|
@ -129,44 +140,56 @@ export default function PluginSettings() {
|
|||
<Modal
|
||||
title={editingIndex !== null ? "Edit Plugin" : "Add Plugin"}
|
||||
open={modalOpen}
|
||||
onOk={handleOk}
|
||||
onOk={form.handleSubmit(handleOk)}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
okText="Save"
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name (identifier)"
|
||||
rules={[{ required: true, message: "Required" }]}
|
||||
extra="Used in URLs and config. No spaces. E.g. litellm-platform-plugin"
|
||||
>
|
||||
<Input placeholder="litellm-platform-plugin" />
|
||||
</Form.Item>
|
||||
<Form.Item name="display_name" label="Display Name" rules={[{ required: true, message: "Required" }]}>
|
||||
<Input placeholder="Agent Control Plane" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="url"
|
||||
label="URL"
|
||||
rules={[
|
||||
{ required: true, message: "Required" },
|
||||
{ type: "url", message: "Must be a valid URL" },
|
||||
]}
|
||||
extra="Base URL of the plugin service"
|
||||
>
|
||||
<Input placeholder="https://your-plugin.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="plugin_key"
|
||||
label="Plugin Key"
|
||||
extra="Optional. The plugin's own credential, injected as Authorization: Bearer <key> only when litellm reverse-proxies API calls to the plugin's backend (/plugin-proxy/<name>/*). Leave blank for plugins that use the forwarded litellm user token (e.g. iframe plugins) — that path uses the user's token, not this key."
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={editingIndex !== null ? "Leave blank to keep current key" : "sk-... (optional)"}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate style={{ marginTop: 16 }}>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Name (identifier)"
|
||||
description="Used in URLs and config. No spaces. E.g. litellm-platform-plugin"
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="litellm-platform-plugin" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="display_name" label="Display Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="Agent Control Plane" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="url" label="URL" description="Base URL of the plugin service">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="https://your-plugin.example.com" />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="plugin_key"
|
||||
label="Plugin Key"
|
||||
description="Optional. The plugin's own credential, injected as Authorization: Bearer <key> only when litellm reverse-proxies API calls to the plugin's backend (/plugin-proxy/<name>/*). Leave blank for plugins that use the forwarded litellm user token (e.g. iframe plugins) — that path uses the user's token, not this key."
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
type={keyVisible ? "text" : "password"}
|
||||
value={field.value ?? ""}
|
||||
placeholder={editingIndex !== null ? "Leave blank to keep current key" : "sk-... (optional)"}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
aria-label={keyVisible ? "Hide plugin key" : "Show plugin key"}
|
||||
>
|
||||
{keyVisible ? <EyeOff /> : <Eye />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
const IPV4 = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}";
|
||||
const SEG = "[a-fA-F\\d]{1,4}";
|
||||
const IPV6 =
|
||||
`(?:(?:${SEG}:){7}(?:${SEG}|:)|` +
|
||||
`(?:${SEG}:){6}(?:${IPV4}|:${SEG}|:)|` +
|
||||
`(?:${SEG}:){5}(?::${IPV4}|(?::${SEG}){1,2}|:)|` +
|
||||
`(?:${SEG}:){4}(?:(?::${SEG}){0,1}:${IPV4}|(?::${SEG}){1,3}|:)|` +
|
||||
`(?:${SEG}:){3}(?:(?::${SEG}){0,2}:${IPV4}|(?::${SEG}){1,4}|:)|` +
|
||||
`(?:${SEG}:){2}(?:(?::${SEG}){0,3}:${IPV4}|(?::${SEG}){1,5}|:)|` +
|
||||
`(?:${SEG}:){1}(?:(?::${SEG}){0,4}:${IPV4}|(?::${SEG}){1,6}|:)|` +
|
||||
`(?::(?:(?::${SEG}){0,5}:${IPV4}|(?::${SEG}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?`;
|
||||
const HOST = "(?:(?:[a-z\\u00a1-\\uffff0-9][-_]*)*[a-z\\u00a1-\\uffff0-9]+)";
|
||||
const DOMAIN = "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*";
|
||||
const TLD = "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))";
|
||||
|
||||
const ANTD_URL_RULE_PATTERN = new RegExp(
|
||||
`(?:^(?:(?:(?:[a-z]+:)?//)|www\\.)(?:\\S+(?::\\S*)?@)?` +
|
||||
`(?:localhost|${IPV4}|${IPV6}|${HOST}${DOMAIN}${TLD})` +
|
||||
`(?::\\d{2,5})?(?:[/?#][^\\s"]*)?$)`,
|
||||
"i",
|
||||
);
|
||||
|
||||
const isUrl = (value: string): boolean => value.length <= 2048 && ANTD_URL_RULE_PATTERN.test(value);
|
||||
|
||||
const pluginShape = {
|
||||
name: z.string().min(1, "Required"),
|
||||
display_name: z.string().min(1, "Required"),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "Required")
|
||||
.refine((value) => value === "" || isUrl(value), "Must be a valid URL"),
|
||||
plugin_key: z.string().optional(),
|
||||
};
|
||||
|
||||
export const pluginSchema = z.object(pluginShape);
|
||||
|
||||
export type PluginFormValues = z.output<typeof pluginSchema>;
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getSSOSettings, updateSSOSettings } from "@/components/networking";
|
||||
|
||||
import UIAccessControlForm from "./UIAccessControlForm";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getSSOSettings: vi.fn(),
|
||||
updateSSOSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetSSOSettings = vi.mocked(getSSOSettings);
|
||||
const mockUpdateSSOSettings = vi.mocked(updateSSOSettings);
|
||||
|
||||
const RESTRICTED_GROUP_PLACEHOLDER = "ui-access-group";
|
||||
const JWT_FIELD_PLACEHOLDER = "groups";
|
||||
const SUBMIT_LABEL = "Update UI Access Control";
|
||||
|
||||
// jsdom has no layout, so a Base UI popup can never leave its unpositioned `pointer-events: none` state.
|
||||
const renderForm = (accessToken: string | null = "sk-test") => {
|
||||
const onSuccess = vi.fn();
|
||||
render(<UIAccessControlForm accessToken={accessToken} onSuccess={onSuccess} />);
|
||||
return { onSuccess, user: userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }) };
|
||||
};
|
||||
|
||||
const chooseAccessMode = async (user: ReturnType<typeof userEvent.setup>, optionLabel: string) => {
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
const options = await screen.findAllByText(optionLabel);
|
||||
await user.click(options[options.length - 1]);
|
||||
};
|
||||
|
||||
const typeInto = async (user: ReturnType<typeof userEvent.setup>, placeholder: string, value: string) => {
|
||||
await user.type(screen.getByPlaceholderText(placeholder), value);
|
||||
};
|
||||
|
||||
const submit = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: SUBMIT_LABEL }));
|
||||
};
|
||||
|
||||
const submittedPayload = () => mockUpdateSSOSettings.mock.calls[0];
|
||||
|
||||
describe("UIAccessControlForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetSSOSettings.mockResolvedValue({ values: {} });
|
||||
mockUpdateSSOSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("sends the nested ui_access_mode payload with every field the user filled in", async () => {
|
||||
const { onSuccess, user } = renderForm();
|
||||
|
||||
await chooseAccessMode(user, "Restricted SSO Group");
|
||||
await typeInto(user, RESTRICTED_GROUP_PLACEHOLDER, "admin-team");
|
||||
await typeInto(user, JWT_FIELD_PLACEHOLDER, "team_groups");
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(1));
|
||||
expect(submittedPayload()).toStrictEqual([
|
||||
"sk-test",
|
||||
{
|
||||
ui_access_mode: {
|
||||
type: "restricted_sso_group",
|
||||
restricted_sso_group: "admin-team",
|
||||
sso_group_jwt_field: "team_groups",
|
||||
},
|
||||
},
|
||||
]);
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('collapses the payload to ui_access_mode "none" for all authenticated users', async () => {
|
||||
const { onSuccess, user } = renderForm();
|
||||
|
||||
await chooseAccessMode(user, "All Authenticated Users");
|
||||
await typeInto(user, JWT_FIELD_PLACEHOLDER, "team_groups");
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(1));
|
||||
expect(submittedPayload()).toStrictEqual(["sk-test", { ui_access_mode: "none" }]);
|
||||
expect(screen.queryByPlaceholderText(RESTRICTED_GROUP_PLACEHOLDER)).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("sends undefined for every field the user never touched", async () => {
|
||||
const { user } = renderForm();
|
||||
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(1));
|
||||
expect(submittedPayload()).toStrictEqual([
|
||||
"sk-test",
|
||||
{
|
||||
ui_access_mode: {
|
||||
type: undefined,
|
||||
restricted_sso_group: undefined,
|
||||
sso_group_jwt_field: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks submission while the restricted SSO group is empty", async () => {
|
||||
const { onSuccess, user } = renderForm();
|
||||
|
||||
await chooseAccessMode(user, "Restricted SSO Group");
|
||||
await submit(user);
|
||||
|
||||
expect(await screen.findByText("Please enter the restricted SSO group")).toBeInTheDocument();
|
||||
expect(mockUpdateSSOSettings).not.toHaveBeenCalled();
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds the fields from a nested ui_access_mode object and resubmits them unchanged", async () => {
|
||||
mockGetSSOSettings.mockResolvedValue({
|
||||
values: {
|
||||
ui_access_mode: {
|
||||
type: "restricted_sso_group",
|
||||
restricted_sso_group: "loaded-group",
|
||||
sso_group_jwt_field: "loaded_field",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { user } = renderForm();
|
||||
|
||||
expect(await screen.findByDisplayValue("loaded-group")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("loaded_field")).toBeInTheDocument();
|
||||
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(1));
|
||||
expect(submittedPayload()).toStrictEqual([
|
||||
"sk-test",
|
||||
{
|
||||
ui_access_mode: {
|
||||
type: "restricted_sso_group",
|
||||
restricted_sso_group: "loaded-group",
|
||||
sso_group_jwt_field: "loaded_field",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(mockGetSSOSettings).toHaveBeenCalledWith("sk-test");
|
||||
});
|
||||
|
||||
it("seeds the fields from the legacy flat structure, preferring team_ids_jwt_field", async () => {
|
||||
mockGetSSOSettings.mockResolvedValue({
|
||||
values: {
|
||||
ui_access_mode: "restricted_sso_group",
|
||||
restricted_sso_group: "legacy-group",
|
||||
team_ids_jwt_field: "legacy_team_ids",
|
||||
sso_group_jwt_field: "legacy_groups",
|
||||
},
|
||||
});
|
||||
const { user } = renderForm();
|
||||
|
||||
expect(await screen.findByDisplayValue("legacy-group")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("legacy_team_ids")).toBeInTheDocument();
|
||||
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(1));
|
||||
expect(submittedPayload()).toStrictEqual([
|
||||
"sk-test",
|
||||
{
|
||||
ui_access_mode: {
|
||||
type: "restricted_sso_group",
|
||||
restricted_sso_group: "legacy-group",
|
||||
sso_group_jwt_field: "legacy_team_ids",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a seeded restricted SSO group out of the payload while its field is hidden", async () => {
|
||||
mockGetSSOSettings.mockResolvedValue({
|
||||
values: {
|
||||
ui_access_mode: "admin_only",
|
||||
restricted_sso_group: "seeded-but-hidden",
|
||||
sso_group_jwt_field: "seeded_field",
|
||||
},
|
||||
});
|
||||
const { user } = renderForm();
|
||||
|
||||
expect(await screen.findByDisplayValue("seeded_field")).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(RESTRICTED_GROUP_PLACEHOLDER)).not.toBeInTheDocument();
|
||||
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(1));
|
||||
expect(submittedPayload()).toStrictEqual([
|
||||
"sk-test",
|
||||
{
|
||||
ui_access_mode: {
|
||||
type: "admin_only",
|
||||
restricted_sso_group: undefined,
|
||||
sso_group_jwt_field: "seeded_field",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores a typed restricted SSO group when its field is shown again", async () => {
|
||||
const { user } = renderForm();
|
||||
|
||||
await chooseAccessMode(user, "Restricted SSO Group");
|
||||
await typeInto(user, RESTRICTED_GROUP_PLACEHOLDER, "typed-then-hidden");
|
||||
await chooseAccessMode(user, "All Authenticated Users");
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(1));
|
||||
expect(submittedPayload()).toStrictEqual(["sk-test", { ui_access_mode: "none" }]);
|
||||
|
||||
await chooseAccessMode(user, "Restricted SSO Group");
|
||||
expect(screen.getByPlaceholderText(RESTRICTED_GROUP_PLACEHOLDER)).toHaveValue("typed-then-hidden");
|
||||
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).toHaveBeenCalledTimes(2));
|
||||
expect(mockUpdateSSOSettings.mock.calls[1]).toStrictEqual([
|
||||
"sk-test",
|
||||
{
|
||||
ui_access_mode: {
|
||||
type: "restricted_sso_group",
|
||||
restricted_sso_group: "typed-then-hidden",
|
||||
sso_group_jwt_field: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("never calls the API without an access token", async () => {
|
||||
const { onSuccess, user } = renderForm(null);
|
||||
|
||||
await submit(user);
|
||||
|
||||
await waitFor(() => expect(mockUpdateSSOSettings).not.toHaveBeenCalled());
|
||||
expect(mockGetSSOSettings).not.toHaveBeenCalled();
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,46 +1,105 @@
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Button as Button2, Select } from "antd";
|
||||
import { Text, TextInput } from "@tremor/react";
|
||||
import { getSSOSettings, updateSSOSettings } from "./networking";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getSSOSettings, updateSSOSettings } from "./networking";
|
||||
|
||||
interface UIAccessControlFormProps {
|
||||
accessToken: string | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// Separate UI Access Control Form Component
|
||||
const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const uiAccessControlSchema = z
|
||||
.object({
|
||||
ui_access_mode_type: z.string().optional(),
|
||||
restricted_sso_group: z.string().optional(),
|
||||
sso_group_jwt_field: z.string().optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (values.ui_access_mode_type !== "restricted_sso_group" || values.restricted_sso_group) {
|
||||
return;
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["restricted_sso_group"],
|
||||
message: "Please enter the restricted SSO group",
|
||||
});
|
||||
});
|
||||
|
||||
type UIAccessControlFormValues = z.output<typeof uiAccessControlSchema>;
|
||||
|
||||
const UI_ACCESS_MODE_OPTIONS = [
|
||||
{ value: "all_authenticated_users", label: "All Authenticated Users" },
|
||||
{ value: "restricted_sso_group", label: "Restricted SSO Group" },
|
||||
] as const;
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||
|
||||
const asString = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
||||
|
||||
const toFormValues = (ssoData: unknown): UIAccessControlFormValues | null => {
|
||||
const values = asRecord(asRecord(ssoData)?.values);
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestedAccessMode = asRecord(values.ui_access_mode);
|
||||
if (nestedAccessMode) {
|
||||
return {
|
||||
ui_access_mode_type: asString(nestedAccessMode.type),
|
||||
restricted_sso_group: asString(nestedAccessMode.restricted_sso_group),
|
||||
sso_group_jwt_field: asString(nestedAccessMode.sso_group_jwt_field),
|
||||
};
|
||||
}
|
||||
|
||||
const legacyAccessMode = asString(values.ui_access_mode);
|
||||
if (legacyAccessMode !== undefined) {
|
||||
return {
|
||||
ui_access_mode_type: legacyAccessMode,
|
||||
restricted_sso_group: asString(values.restricted_sso_group),
|
||||
sso_group_jwt_field: asString(values.team_ids_jwt_field) || asString(values.sso_group_jwt_field),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken, onSuccess }) => {
|
||||
const form = useZodForm(uiAccessControlSchema, { defaultValues: {} });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const uiAccessModeType = useWatch({ control: form.control, name: "ui_access_mode_type" });
|
||||
|
||||
// Load existing UI access control settings
|
||||
useEffect(() => {
|
||||
const loadUIAccessSettings = async () => {
|
||||
if (accessToken) {
|
||||
try {
|
||||
const ssoData = await getSSOSettings(accessToken);
|
||||
if (ssoData && ssoData.values) {
|
||||
// Handle nested ui_access_mode structure
|
||||
const uiAccessMode = ssoData.values.ui_access_mode;
|
||||
let formValues = {};
|
||||
|
||||
if (uiAccessMode && typeof uiAccessMode === "object") {
|
||||
formValues = {
|
||||
ui_access_mode_type: uiAccessMode.type,
|
||||
restricted_sso_group: uiAccessMode.restricted_sso_group,
|
||||
sso_group_jwt_field: uiAccessMode.sso_group_jwt_field,
|
||||
};
|
||||
} else if (typeof uiAccessMode === "string") {
|
||||
// Handle legacy flat structure
|
||||
formValues = {
|
||||
ui_access_mode_type: uiAccessMode,
|
||||
restricted_sso_group: ssoData.values.restricted_sso_group,
|
||||
sso_group_jwt_field: ssoData.values.team_ids_jwt_field || ssoData.values.sso_group_jwt_field,
|
||||
};
|
||||
}
|
||||
|
||||
form.setFieldsValue(formValues);
|
||||
const formValues = toFormValues(await getSSOSettings(accessToken));
|
||||
if (formValues) {
|
||||
form.setValue("ui_access_mode_type", formValues.ui_access_mode_type);
|
||||
form.setValue("restricted_sso_group", formValues.restricted_sso_group);
|
||||
form.setValue("sso_group_jwt_field", formValues.sso_group_jwt_field);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load UI access settings:", error);
|
||||
|
|
@ -51,7 +110,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
loadUIAccessSettings();
|
||||
}, [accessToken, form]);
|
||||
|
||||
const handleUIAccessSubmit = async (formValues: Record<string, any>) => {
|
||||
const handleUIAccessSubmit = async (formValues: UIAccessControlFormValues) => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("No access token available");
|
||||
return;
|
||||
|
|
@ -59,23 +118,16 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Transform form data to match API expected structure
|
||||
let apiPayload;
|
||||
|
||||
if (formValues.ui_access_mode_type === "all_authenticated_users") {
|
||||
// Set ui_access_mode to none when all_authenticated_users is selected
|
||||
apiPayload = {
|
||||
ui_access_mode: "none",
|
||||
};
|
||||
} else {
|
||||
apiPayload = {
|
||||
ui_access_mode: {
|
||||
type: formValues.ui_access_mode_type,
|
||||
restricted_sso_group: formValues.restricted_sso_group,
|
||||
sso_group_jwt_field: formValues.sso_group_jwt_field,
|
||||
},
|
||||
};
|
||||
}
|
||||
const apiPayload =
|
||||
formValues.ui_access_mode_type === "all_authenticated_users"
|
||||
? { ui_access_mode: "none" }
|
||||
: {
|
||||
ui_access_mode: {
|
||||
type: formValues.ui_access_mode_type,
|
||||
restricted_sso_group: formValues.restricted_sso_group,
|
||||
sso_group_jwt_field: formValues.sso_group_jwt_field,
|
||||
},
|
||||
};
|
||||
|
||||
await updateSSOSettings(accessToken, apiPayload);
|
||||
onSuccess();
|
||||
|
|
@ -87,65 +139,83 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
}
|
||||
};
|
||||
|
||||
const submitMountedValues = (formValues: UIAccessControlFormValues) =>
|
||||
handleUIAccessSubmit(
|
||||
formValues.ui_access_mode_type === "restricted_sso_group"
|
||||
? formValues
|
||||
: { ...formValues, restricted_sso_group: undefined },
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "16px" }}>
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<Text style={{ fontSize: "14px", color: "#6b7280" }}>
|
||||
Configure who can access the UI interface and how group information is extracted from JWT tokens.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form form={form} onFinish={handleUIAccessSubmit} layout="vertical">
|
||||
<Form.Item label="UI Access Mode" name="ui_access_mode_type" tooltip="Controls who can access the UI interface">
|
||||
<Select placeholder="Select access mode">
|
||||
<Select.Option value="all_authenticated_users">All Authenticated Users</Select.Option>
|
||||
<Select.Option value="restricted_sso_group">Restricted SSO Group</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.ui_access_mode_type !== currentValues.ui_access_mode_type
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const uiAccessModeType = getFieldValue("ui_access_mode_type");
|
||||
return uiAccessModeType === "restricted_sso_group" ? (
|
||||
<Form.Item
|
||||
label="Restricted SSO Group"
|
||||
name="restricted_sso_group"
|
||||
rules={[{ required: true, message: "Please enter the restricted SSO group" }]}
|
||||
>
|
||||
<TextInput placeholder="ui-access-group" />
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="SSO Group JWT Field"
|
||||
name="sso_group_jwt_field"
|
||||
tooltip="JWT field name that contains team/group information. Use dot notation to access nested fields."
|
||||
>
|
||||
<TextInput placeholder="groups" />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "16px" }}>
|
||||
<Button2
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{
|
||||
backgroundColor: "#6366f1",
|
||||
borderColor: "#6366f1",
|
||||
}}
|
||||
>
|
||||
Update UI Access Control
|
||||
</Button2>
|
||||
<TooltipProvider>
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure who can access the UI interface and how group information is extracted from JWT tokens.
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(submitMountedValues)} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ui_access_mode_type"
|
||||
label={labelWithHint("UI Access Mode", "Controls who can access the UI interface")}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select
|
||||
items={UI_ACCESS_MODE_OPTIONS}
|
||||
value={value ?? null}
|
||||
onValueChange={(selected) => onChange(selected ?? undefined)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className="w-full"
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
<SelectValue placeholder="Select access mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UI_ACCESS_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{uiAccessModeType === "restricted_sso_group" && (
|
||||
<FormField control={form.control} name="restricted_sso_group" label="Restricted SSO Group">
|
||||
{({ ref, value, ...field }) => (
|
||||
<Input {...field} ref={ref} value={value ?? ""} placeholder="ui-access-group" />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sso_group_jwt_field"
|
||||
label={labelWithHint(
|
||||
"SSO Group JWT Field",
|
||||
"JWT field name that contains team/group information. Use dot notation to access nested fields.",
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} placeholder="groups" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Update UI Access Control
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface SearchSelectProps {
|
|||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputId?: string;
|
||||
allowClear?: boolean;
|
||||
}
|
||||
|
||||
const matchesQuery = (option: SearchSelectOption, query: string): boolean => {
|
||||
|
|
@ -42,6 +43,7 @@ export function SearchSelect({
|
|||
disabled = false,
|
||||
className,
|
||||
inputId,
|
||||
allowClear = true,
|
||||
}: SearchSelectProps) {
|
||||
const selected =
|
||||
value === undefined || value === ""
|
||||
|
|
@ -63,7 +65,7 @@ export function SearchSelect({
|
|||
<ComboboxInput
|
||||
id={inputId}
|
||||
placeholder={placeholder}
|
||||
showClear={value != null && value !== ""}
|
||||
showClear={allowClear && value != null && value !== ""}
|
||||
className={`h-8 w-full text-sm ${className ?? ""}`}
|
||||
/>
|
||||
<ComboboxContent side="bottom" collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue