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:
yuneng-jiang 2026-08-18 11:47:06 -07:00 committed by GitHub
parent d6fe9712fa
commit aa90828811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1279 additions and 388 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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