= ({
onOk={handleAddSSOOk}
onCancel={handleAddSSOCancel}
>
-
-
- {Object.entries(ssoProviderLogoMap).map(([value, logo]) => (
-
-
- {logo && (
-
- )}
-
- {ssoProviderDisplayNames[value] || value.charAt(0).toUpperCase() + value.slice(1) + " SSO"}
-
-
-
- ))}
-
-
-
- prevValues.sso_provider !== currentValues.sso_provider}
- >
- {({ getFieldValue }) => {
- const provider = getFieldValue("sso_provider");
- return provider ? renderProviderFields(provider) : null;
- }}
-
-
-
-
-
- value?.trim()}
- rules={[
- { required: true, message: "Please enter the proxy base url" },
- {
- pattern: /^https?:\/\/.+/,
- message: "URL must start with http:// or https://",
- },
- {
- validator: (_, value) => {
- // Only check for trailing slash if the URL starts with http:// or https://
- if (value && /^https?:\/\/.+/.test(value) && value.endsWith("/")) {
- return Promise.reject("URL must not end with a trailing slash");
- }
- return Promise.resolve();
- },
- },
- ]}
- >
-
-
-
- prevValues.sso_provider !== currentValues.sso_provider}
- >
- {({ getFieldValue }) => {
- const provider = getFieldValue("sso_provider");
- return provider === "okta" || provider === "generic" ? (
-
-
-
- ) : null;
- }}
-
-
-
- prevValues.use_role_mappings !== currentValues.use_role_mappings
- }
- >
- {({ getFieldValue }) => {
- const useRoleMappings = getFieldValue("use_role_mappings");
- return useRoleMappings ? (
-
-
-
- ) : null;
- }}
-
-
-
- prevValues.use_role_mappings !== currentValues.use_role_mappings
- }
- >
- {({ getFieldValue }) => {
- const useRoleMappings = getFieldValue("use_role_mappings");
- return useRoleMappings ? (
- <>
-
-
- Internal Viewer
- Internal User
- Admin Viewer
- Proxy Admin
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : null;
- }}
-
- >
-
+
-
+
+
+ {provider ? renderProviderFields(provider) : null}
+
+
+ {showRoleMappingToggle && }
+ {useRoleMappings && (
+ <>
+
+
+ >
+ )}
+
+
+ {ssoConfigured && (
+ setIsClearConfirmModalVisible(true)}>
+ Clear
+
+ )}
+ Save
+
+
+
{/* Clear Confirmation Modal */}
@@ -448,7 +322,9 @@ const SSOModals: React.FC = ({
3. Confirm your SSO is configured correctly and you can login on the new Tab
4. If Step 3 is successful, you can close this tab
- Done
+
+ Done
+
>
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.test.tsx
new file mode 100644
index 00000000000..2eb2137ab6d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.test.tsx
@@ -0,0 +1,190 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "../../../../../tests/test-utils";
+import EditHashicorpVaultModal from "./EditHashicorpVaultModal";
+import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig";
+import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig";
+
+vi.mock("@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig", () => ({
+ useHashicorpVaultConfig: vi.fn(),
+}));
+
+vi.mock("@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig", () => ({
+ useUpdateHashicorpVaultConfig: vi.fn(),
+}));
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => ({ accessToken: "sk-access-token" }),
+}));
+
+vi.mock("@/lib/toast", () => ({
+ toast: { success: vi.fn(), fromError: vi.fn() },
+}));
+
+const ALL_FIELDS = [
+ "vault_addr",
+ "vault_namespace",
+ "vault_mount_name",
+ "vault_path_prefix",
+ "vault_token",
+ "approle_role_id",
+ "approle_secret_id",
+ "approle_mount_path",
+ "client_cert",
+ "client_key",
+ "vault_cert_role",
+] as const;
+
+const propertiesFor = (fields: readonly string[]) =>
+ Object.fromEntries(fields.map((name) => [name, { description: `${name} description` }]));
+
+const mutate = vi.fn();
+
+const setup = (options?: { values?: Record; fields?: readonly string[] }) => {
+ vi.mocked(useHashicorpVaultConfig).mockReturnValue({
+ data: {
+ field_schema: { properties: propertiesFor(options?.fields ?? ALL_FIELDS) },
+ values: options?.values ?? {},
+ },
+ } as unknown as ReturnType);
+
+ vi.mocked(useUpdateHashicorpVaultConfig).mockReturnValue({
+ mutate,
+ isPending: false,
+ } as unknown as ReturnType);
+};
+
+const renderModal = (onSuccess = vi.fn(), onCancel = vi.fn()) =>
+ renderWithProviders( );
+
+const save = async (user: ReturnType) =>
+ user.click(screen.getByRole("button", { name: "Save" }));
+
+describe("EditHashicorpVaultModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("clears untouched non-sensitive fields and omits untouched sensitive fields", async () => {
+ setup({
+ values: {
+ vault_addr: "https://vault.example.com",
+ vault_namespace: "team-ns",
+ vault_token: "super-secret-token",
+ approle_secret_id: "super-secret-id",
+ },
+ });
+ const user = userEvent.setup();
+ renderModal();
+
+ await save(user);
+
+ await waitFor(() => {
+ expect(mutate).toHaveBeenCalledTimes(1);
+ });
+ expect(mutate.mock.calls[0][0]).toEqual({
+ vault_addr: "https://vault.example.com",
+ vault_namespace: "team-ns",
+ vault_mount_name: "",
+ vault_path_prefix: "",
+ approle_role_id: "",
+ approle_mount_path: "",
+ client_cert: "",
+ vault_cert_role: "",
+ });
+ });
+
+ it("sends a sensitive field only once it is typed into", async () => {
+ setup({ values: { vault_addr: "https://vault.example.com", vault_token: "super-secret-token" } });
+ const user = userEvent.setup();
+ renderModal();
+
+ await user.type(screen.getByLabelText("Token"), "rotated-token");
+ await save(user);
+
+ await waitFor(() => {
+ expect(mutate).toHaveBeenCalledTimes(1);
+ });
+ expect(mutate.mock.calls[0][0]).toMatchObject({ vault_token: "rotated-token" });
+ });
+
+ it("never seeds a stored secret into its input", () => {
+ setup({ values: { vault_token: "super-secret-token", approle_secret_id: "super-secret-id" } });
+ renderModal();
+
+ expect(screen.getByLabelText("Token")).toHaveValue("");
+ expect(screen.getByLabelText("Secret ID")).toHaveValue("");
+ });
+
+ it("renders only the fields the schema declares, and sends only those", async () => {
+ setup({ fields: ["vault_addr", "vault_token"], values: { vault_addr: "https://vault.example.com" } });
+ const user = userEvent.setup();
+ renderModal();
+
+ expect(screen.queryByLabelText("Namespace")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("Role ID")).not.toBeInTheDocument();
+
+ await save(user);
+
+ await waitFor(() => {
+ expect(mutate).toHaveBeenCalledTimes(1);
+ });
+ expect(mutate.mock.calls[0][0]).toEqual({ vault_addr: "https://vault.example.com" });
+ });
+
+ it("blocks the submit when the vault address does not start with http", async () => {
+ setup({ values: {} });
+ const user = userEvent.setup();
+ renderModal();
+
+ await user.type(screen.getByLabelText("Vault Address"), "vault.example.com");
+ await save(user);
+
+ expect(await screen.findByText("Must start with http:// or https://")).toBeInTheDocument();
+ expect(mutate).not.toHaveBeenCalled();
+ });
+
+ it("accepts an empty vault address, because the pattern rule is not a required rule", async () => {
+ setup({ values: {} });
+ const user = userEvent.setup();
+ renderModal();
+
+ await save(user);
+
+ await waitFor(() => {
+ expect(mutate).toHaveBeenCalledTimes(1);
+ });
+ expect(mutate.mock.calls[0][0]).toMatchObject({ vault_addr: "" });
+ });
+
+ it("tells the admin a stored secret is kept when the field is left blank", () => {
+ setup({ values: { vault_token: "super-secret-token" } });
+ renderModal();
+
+ expect(screen.getByLabelText("Token")).toHaveAttribute(
+ "placeholder",
+ "Leave blank to keep existing (super-secret-token)",
+ );
+ });
+
+ it("falls back to the schema description when no secret is stored yet", () => {
+ setup({ values: {} });
+ renderModal();
+
+ expect(screen.getByLabelText("Token")).toHaveAttribute("placeholder", "vault_token description");
+ });
+
+ it("closes without saving when cancelled", async () => {
+ setup({ values: {} });
+ const onCancel = vi.fn();
+ const user = userEvent.setup();
+ renderModal(vi.fn(), onCancel);
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(mutate).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx
index 4904fa016a9..0658f28611a 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx
@@ -4,17 +4,26 @@ import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides
import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { toast } from "@/lib/toast";
-import { Button, Divider, Form, Input, Modal, Space, Typography } from "antd";
-import React, { useEffect } from "react";
+import { Modal } from "antd";
+import React, { useMemo } from "react";
+import { z } from "zod/v4";
+import { FieldGroup } from "@/components/shared/form/field";
+import { FormField } from "@/components/shared/form/FormField";
+import { PasswordInput } from "@/components/shared/PasswordInput";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
+import { Separator } from "@/components/ui/separator";
+import { useZodForm } from "@/lib/forms/useZodForm";
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
-interface FieldGroup {
+interface VaultFieldGroup {
title: string;
subtitle?: string;
fields: string[];
}
-const FIELD_GROUPS: FieldGroup[] = [
+const FIELD_GROUPS: VaultFieldGroup[] = [
{
title: "Connection",
fields: ["vault_addr", "vault_namespace", "vault_mount_name", "vault_path_prefix"],
@@ -36,6 +45,22 @@ const FIELD_GROUPS: FieldGroup[] = [
},
];
+type VaultFormValues = Record;
+
+const buildSchema = (fields: readonly string[]): z.ZodType =>
+ z.object(
+ Object.fromEntries(
+ fields.map((name) => [
+ name,
+ name === "vault_addr"
+ ? z.string().refine((value) => value.length === 0 || /^https?:\/\/.+/.test(value), {
+ message: "Must start with http:// or https://",
+ })
+ : z.string(),
+ ]),
+ ),
+ ) as unknown as z.ZodType;
+
interface EditHashicorpVaultModalProps {
isVisible: boolean;
onCancel: () => void;
@@ -43,41 +68,40 @@ interface EditHashicorpVaultModalProps {
}
const EditHashicorpVaultModal: React.FC = ({ isVisible, onCancel, onSuccess }) => {
- const [form] = Form.useForm();
const { accessToken } = useAuthorized();
const { data } = useHashicorpVaultConfig();
const { mutate, isPending } = useUpdateHashicorpVaultConfig(accessToken);
- const schema = data?.field_schema;
- const properties = schema?.properties ?? {};
- const rawValues = data?.values ?? {};
+ const properties: Record = useMemo(
+ () => data?.field_schema?.properties ?? {},
+ [data],
+ );
+ const rawValues: Record = useMemo(() => data?.values ?? {}, [data]);
- useEffect(() => {
- if (isVisible && data) {
- form.resetFields();
- // Only set non-sensitive fields — sensitive ones show as placeholders
- const formValues: Record = {};
- for (const [key, value] of Object.entries(rawValues)) {
- if (!SENSITIVE_FIELDS.has(key)) {
- formValues[key] = value;
- }
- }
- form.setFieldsValue(formValues);
- }
- }, [isVisible, data, form]);
+ const visibleFields = useMemo(
+ () => FIELD_GROUPS.flatMap((group) => group.fields).filter((name) => properties[name] !== undefined),
+ [properties],
+ );
- const handleSubmit = (formValues: Record) => {
- const config: Record = {};
- for (const [key, value] of Object.entries(formValues)) {
- if (value !== undefined && value !== null && value !== "") {
- // Non-empty value → update
- config[key] = value;
- } else if (!SENSITIVE_FIELDS.has(key)) {
- // Non-sensitive field cleared → send "" to clear it on the backend
- config[key] = "";
- }
- // Sensitive field left blank → omit from payload (keep existing)
- }
+ const seededValues = useMemo(
+ () =>
+ Object.fromEntries(
+ visibleFields.map((name) => [name, SENSITIVE_FIELDS.has(name) ? "" : ((rawValues[name] ?? "") as string)]),
+ ),
+ [visibleFields, rawValues],
+ );
+
+ const schema = useMemo(() => buildSchema(visibleFields), [visibleFields]);
+ const form = useZodForm(schema, { values: seededValues });
+
+ const handleSubmit = (formValues: VaultFormValues) => {
+ const config: Record = Object.fromEntries(
+ Object.entries(formValues).flatMap(([key, value]) => {
+ if (value !== undefined && value !== null && value !== "") return [[key, value]];
+ if (!SENSITIVE_FIELDS.has(key)) return [[key, ""]];
+ return [];
+ }),
+ );
mutate(config, {
onSuccess: () => {
@@ -91,7 +115,7 @@ const EditHashicorpVaultModal: React.FC = ({ isVis
};
const handleCancel = () => {
- form.resetFields();
+ form.reset(seededValues);
onCancel();
};
@@ -99,20 +123,21 @@ const EditHashicorpVaultModal: React.FC = ({ isVis
const fieldSchema = properties[fieldName];
if (!fieldSchema) return null;
- const rules =
- fieldName === "vault_addr"
- ? [{ pattern: /^https?:\/\/.+/, message: "Must start with http:// or https://" }]
- : undefined;
-
const isSensitive = SENSITIVE_FIELDS.has(fieldName);
const existingValue = rawValues[fieldName];
const hasExistingValue = isSensitive && existingValue != null && existingValue !== "";
const placeholder = hasExistingValue ? `Leave blank to keep existing (${existingValue})` : fieldSchema?.description;
return (
-
- {isSensitive ? : }
-
+
+ {({ ref, ...field }) =>
+ isSensitive ? (
+
+ ) : (
+
+ )
+ }
+
);
};
@@ -122,33 +147,28 @@ const EditHashicorpVaultModal: React.FC = ({ isVis
open={isVisible}
width={700}
footer={
-
-
+
+
Cancel
- form.submit()}>
+ void form.handleSubmit(handleSubmit)()}>
+ {isPending && }
{isPending ? "Saving..." : "Save"}
-
+
}
onCancel={handleCancel}
>
-
+
);
};
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx
index a1f62ea4939..fcbb6300d70 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx
@@ -2,9 +2,16 @@
import { toast } from "@/lib/toast";
import { parseErrorMessage } from "@/components/shared/errorUtils";
-import { Button, Form, Modal, Space } from "antd";
+import { Modal } from "antd";
import React from "react";
-import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
+import BaseSSOSettingsForm, {
+ emptySSOSettingsFormValues,
+ submitMountedSSOValues,
+ useSSOSettingsForm,
+ type SSOSettingsFormValues,
+} from "./BaseSSOSettingsForm";
+import { Button } from "@/components/ui/button";
+import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
import { processSSOSettingsPayload } from "../utils";
@@ -15,11 +22,10 @@ interface AddSSOSettingsModalProps {
}
const AddSSOSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const { mutateAsync, isPending } = useEditSSOSettings();
- // Enhanced form submission handler
- const handleFormSubmit = async (formValues: Record) => {
+ const handleFormSubmit = async (formValues: SSOSettingsFormValues) => {
const payload = processSSOSettingsPayload(formValues);
await mutateAsync(payload, {
@@ -34,7 +40,7 @@ const AddSSOSettingsModal: React.FC = ({ isVisible, on
};
const handleCancel = () => {
- form.resetFields();
+ form.reset(emptySSOSettingsFormValues);
onCancel();
};
@@ -44,14 +50,19 @@ const AddSSOSettingsModal: React.FC = ({ isVisible, on
open={isVisible}
width={800}
footer={
-
-
+
+
Cancel
- form.submit()}>
+
+ {isPending && }
{isPending ? "Adding..." : "Add SSO"}
-
+
}
onCancel={handleCancel}
>
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx
index 8043e41a2de..54566e1d75a 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx
@@ -1,8 +1,20 @@
-import { Form } from "antd";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../../../../tests/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
-import BaseSSOSettingsForm, { renderProviderFields, ssoProviderConfigs } from "./BaseSSOSettingsForm";
+import BaseSSOSettingsForm, {
+ emptySSOSettingsFormValues,
+ renderProviderFields,
+ ssoProviderConfigs,
+ submitMountedSSOValues,
+ useSSOSettingsForm,
+} from "./BaseSSOSettingsForm";
+
+const user = () => userEvent.setup({ pointerEventsCheck: 0 });
+
+const openProviderDropdown = async () => {
+ await user().click(screen.getByLabelText("SSO Provider"));
+};
describe("BaseSSOSettingsForm", () => {
afterEach(() => {
@@ -11,7 +23,7 @@ describe("BaseSSOSettingsForm", () => {
it("should render", () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -26,7 +38,7 @@ describe("BaseSSOSettingsForm", () => {
it("should render provider fields when provider is selected", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -34,13 +46,10 @@ describe("BaseSSOSettingsForm", () => {
renderWithProviders( );
- const providerSelect = screen.getByLabelText("SSO Provider");
- await act(async () => {
- fireEvent.mouseDown(providerSelect);
- });
+ await openProviderDropdown();
const googleOption = await screen.findByText(/google sso/i);
- fireEvent.click(googleOption);
+ await user().click(googleOption);
await waitFor(() => {
expect(screen.getByText("Google Client ID")).toBeInTheDocument();
@@ -50,7 +59,7 @@ describe("BaseSSOSettingsForm", () => {
it("should show role mappings fields for okta provider", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -58,13 +67,10 @@ describe("BaseSSOSettingsForm", () => {
renderWithProviders( );
- const providerSelect = screen.getByLabelText("SSO Provider");
- await act(async () => {
- fireEvent.mouseDown(providerSelect);
- });
+ await openProviderDropdown();
const oktaOption = await screen.findByText(/okta/i);
- fireEvent.click(oktaOption);
+ await user().click(oktaOption);
await waitFor(() => {
expect(screen.getByText("Use Role Mappings")).toBeInTheDocument();
@@ -73,7 +79,7 @@ describe("BaseSSOSettingsForm", () => {
it("should validate proxy base url format", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -94,7 +100,7 @@ describe("BaseSSOSettingsForm", () => {
it("should validate proxy base url trailing slash", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -115,7 +121,7 @@ describe("BaseSSOSettingsForm", () => {
it("should show role mappings fields when use_role_mappings is checked for generic provider", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -123,22 +129,16 @@ describe("BaseSSOSettingsForm", () => {
renderWithProviders( );
- const providerSelect = screen.getByLabelText("SSO Provider");
- await act(async () => {
- fireEvent.mouseDown(providerSelect);
- });
+ await openProviderDropdown();
const genericOption = await screen.findByText(/generic sso/i);
- fireEvent.click(genericOption);
+ await user().click(genericOption);
await waitFor(() => {
expect(screen.getByText("Use Role Mappings")).toBeInTheDocument();
});
- const checkbox = screen.getByLabelText("Use Role Mappings");
- await act(async () => {
- fireEvent.click(checkbox);
- });
+ await user().click(screen.getAllByLabelText("Use Role Mappings")[0]);
await waitFor(() => {
expect(screen.getByText("Group Claim")).toBeInTheDocument();
@@ -148,7 +148,7 @@ describe("BaseSSOSettingsForm", () => {
it("should show team mappings checkbox for okta provider", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -156,13 +156,10 @@ describe("BaseSSOSettingsForm", () => {
renderWithProviders( );
- const providerSelect = screen.getByLabelText("SSO Provider");
- await act(async () => {
- fireEvent.mouseDown(providerSelect);
- });
+ await openProviderDropdown();
const oktaOption = await screen.findByText(/okta/i);
- fireEvent.click(oktaOption);
+ await user().click(oktaOption);
await waitFor(() => {
expect(screen.getByText("Use Team Mappings")).toBeInTheDocument();
@@ -171,7 +168,7 @@ describe("BaseSSOSettingsForm", () => {
it("should show team mappings checkbox for generic provider", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -179,13 +176,10 @@ describe("BaseSSOSettingsForm", () => {
renderWithProviders( );
- const providerSelect = screen.getByLabelText("SSO Provider");
- await act(async () => {
- fireEvent.mouseDown(providerSelect);
- });
+ await openProviderDropdown();
const genericOption = await screen.findByText(/generic sso/i);
- fireEvent.click(genericOption);
+ await user().click(genericOption);
await waitFor(() => {
expect(screen.getByText("Use Team Mappings")).toBeInTheDocument();
@@ -194,7 +188,7 @@ describe("BaseSSOSettingsForm", () => {
it("should show team IDs JWT field when use_team_mappings is checked for okta provider", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -202,22 +196,16 @@ describe("BaseSSOSettingsForm", () => {
renderWithProviders( );
- const providerSelect = screen.getByLabelText("SSO Provider");
- await act(async () => {
- fireEvent.mouseDown(providerSelect);
- });
+ await openProviderDropdown();
const oktaOption = await screen.findByText(/okta/i);
- fireEvent.click(oktaOption);
+ await user().click(oktaOption);
await waitFor(() => {
expect(screen.getByText("Use Team Mappings")).toBeInTheDocument();
});
- const checkbox = screen.getByLabelText("Use Team Mappings");
- await act(async () => {
- fireEvent.click(checkbox);
- });
+ await user().click(screen.getAllByLabelText("Use Team Mappings")[0]);
await waitFor(() => {
expect(screen.getByText("Team IDs JWT Field")).toBeInTheDocument();
@@ -226,7 +214,7 @@ describe("BaseSSOSettingsForm", () => {
it("should not show team mappings checkbox for google provider", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
const handleSubmit = vi.fn();
return ;
@@ -234,13 +222,10 @@ describe("BaseSSOSettingsForm", () => {
renderWithProviders( );
- const providerSelect = screen.getByLabelText("SSO Provider");
- await act(async () => {
- fireEvent.mouseDown(providerSelect);
- });
+ await openProviderDropdown();
const googleOption = await screen.findByText(/google sso/i);
- fireEvent.click(googleOption);
+ await user().click(googleOption);
await waitFor(() => {
expect(screen.getByText("Google Client ID")).toBeInTheDocument();
@@ -297,9 +282,9 @@ describe("renderProviderFields", () => {
// to the provider default. Dropping the field from ssoProviderConfigs must
// fail here rather than silently in production.
const handleSubmit = vi.fn();
- let form: any;
+ let form!: ReturnType;
const TestWrapper = () => {
- const [formInstance] = Form.useForm();
+ const formInstance = useSSOSettingsForm("sso-settings");
form = formInstance;
return ;
};
@@ -308,7 +293,8 @@ describe("renderProviderFields", () => {
// Mirror EditSSOSettingsModal hydrating the form from the GET response.
await act(async () => {
- form.setFieldsValue({
+ form.reset({
+ ...emptySSOSettingsFormValues,
sso_provider: "generic",
generic_client_id: "client-id",
generic_client_secret: "client-secret",
@@ -323,8 +309,8 @@ describe("renderProviderFields", () => {
// The admin edits something else entirely and saves.
await act(async () => {
- form.setFieldsValue({ generic_token_endpoint: "https://idp.example.com/token/v2" });
- form.submit();
+ form.setValue("generic_token_endpoint", "https://idp.example.com/token/v2");
+ submitMountedSSOValues(form, "sso-settings", handleSubmit)();
});
await waitFor(() => {
@@ -339,15 +325,13 @@ describe("renderProviderFields", () => {
it("renders provider logos in the dropdown and falls back to a letter avatar on load error", async () => {
const TestWrapper = () => {
- const [form] = Form.useForm();
+ const form = useSSOSettingsForm("sso-settings");
return ;
};
renderWithProviders( );
- await act(async () => {
- fireEvent.mouseDown(screen.getByLabelText("SSO Provider"));
- });
+ await openProviderDropdown();
await waitFor(() => {
expect(screen.getAllByAltText("Google SSO logo").length).toBeGreaterThan(0);
@@ -372,4 +356,50 @@ describe("renderProviderFields", () => {
expect(screen.getByText("O")).toBeInTheDocument();
});
});
+
+ it("blocks the submit and names the missing provider credential", async () => {
+ const handleSubmit = vi.fn();
+ let form!: ReturnType;
+ const TestWrapper = () => {
+ const formInstance = useSSOSettingsForm("sso-settings");
+ form = formInstance;
+ return ;
+ };
+
+ renderWithProviders( );
+
+ await act(async () => {
+ form.reset({
+ ...emptySSOSettingsFormValues,
+ sso_provider: "google",
+ google_client_secret: "a-secret",
+ user_email: "admin@example.com",
+ proxy_base_url: "https://gateway.example.com",
+ });
+ });
+
+ await act(async () => {
+ submitMountedSSOValues(form, "sso-settings", handleSubmit)();
+ });
+
+ expect(await screen.findByText("Please enter the google client id")).toBeInTheDocument();
+ expect(handleSubmit).not.toHaveBeenCalled();
+ });
+
+ it("shows the effective default role on an untouched form", async () => {
+ let form!: ReturnType;
+ const TestWrapper = () => {
+ const formInstance = useSSOSettingsForm("sso-settings");
+ form = formInstance;
+ return ;
+ };
+
+ renderWithProviders( );
+
+ await act(async () => {
+ form.reset({ ...emptySSOSettingsFormValues, sso_provider: "okta", use_role_mappings: true });
+ });
+
+ expect(await screen.findByLabelText("Default Role")).toHaveTextContent("Internal User");
+ });
});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
index 7deac9cbbbc..26d0785529c 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx
@@ -1,29 +1,65 @@
"use client";
-import { TextInput } from "@tremor/react";
-import { Checkbox, Form, Input, Select } from "antd";
import React from "react";
+import { FormProvider, useFormContext, useWatch, type UseFormReturn } from "react-hook-form";
+import { z } from "zod/v4";
import { ssoProviderLogoMap, ssoProviderDisplayNames } from "../constants";
import { Logo } from "@/components/molecules/logo/Logo";
+import { FieldGroup } from "@/components/shared/form/field";
+import { FormField } from "@/components/shared/form/FormField";
+import { PasswordInput } from "@/components/shared/PasswordInput";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { useZodForm } from "@/lib/forms/useZodForm";
-export interface BaseSSOSettingsFormProps {
- form: any; // Replace with proper Form type if available
- onFormSubmit: (formValues: Record) => Promise;
+export interface SSOSettingsFormValues {
+ sso_provider?: string;
+ google_client_id?: string;
+ google_client_secret?: string;
+ microsoft_client_id?: string;
+ microsoft_client_secret?: string;
+ microsoft_tenant?: string;
+ generic_client_id?: string;
+ generic_client_secret?: string;
+ generic_authorization_endpoint?: string;
+ generic_token_endpoint?: string;
+ generic_userinfo_endpoint?: string;
+ generic_scope?: string;
+ saml_idp_metadata_url?: string;
+ saml_idp_metadata_xml?: string;
+ saml_sp_entity_id?: string;
+ saml_allow_unsolicited?: boolean;
+ user_email?: string;
+ proxy_base_url?: string;
+ use_role_mappings?: boolean;
+ group_claim?: string;
+ default_role?: string;
+ proxy_admin_teams?: string;
+ admin_viewer_teams?: string;
+ internal_user_teams?: string;
+ internal_viewer_teams?: string;
+ use_team_mappings?: boolean;
+ team_ids_jwt_field?: string;
+}
+
+export interface BaseSSOSettingsFormProps {
+ form: UseFormReturn;
+ onFormSubmit: (formValues: SSOSettingsFormValues) => Promise;
}
-// Define the SSO provider configuration type
export interface SSOProviderConfig {
envVarMap: Record;
fields: Array<{
label: string;
- name: string;
+ name: keyof SSOSettingsFormValues;
placeholder?: string;
required?: boolean;
type?: "password" | "textarea" | "checkbox";
}>;
}
-// Define configurations for each SSO provider
export const ssoProviderConfigs: Record = {
google: {
envVarMap: {
@@ -128,224 +164,381 @@ export const ssoProviderConfigs: Record = {
},
};
-// Helper function to render provider fields
+const ROLE_MAPPING_TEAM_FIELDS = [
+ "proxy_admin_teams",
+ "admin_viewer_teams",
+ "internal_user_teams",
+ "internal_viewer_teams",
+] as const;
+
+const supportsMappings = (provider: string | undefined): boolean => provider === "okta" || provider === "generic";
+
+const providerFieldNames = (provider: string | undefined): readonly string[] =>
+ provider ? ssoProviderConfigs[provider]?.fields.map((field) => field.name) ?? [] : [];
+
+export type SSOFormVariant = "sso-settings" | "admin-panel";
+
+export const mountedSSOFieldNames = (values: SSOSettingsFormValues, variant: SSOFormVariant): readonly string[] => {
+ const provider = values.sso_provider;
+ const showMappingToggles = supportsMappings(provider);
+ const roleFieldsVisible =
+ variant === "sso-settings"
+ ? Boolean(values.use_role_mappings) && showMappingToggles
+ : Boolean(values.use_role_mappings);
+ const teamFieldsVisible = variant === "sso-settings" && Boolean(values.use_team_mappings) && showMappingToggles;
+
+ return [
+ "sso_provider",
+ ...providerFieldNames(provider),
+ "user_email",
+ "proxy_base_url",
+ ...(showMappingToggles ? ["use_role_mappings"] : []),
+ ...(roleFieldsVisible ? ["group_claim", "default_role", ...ROLE_MAPPING_TEAM_FIELDS] : []),
+ ...(variant === "sso-settings" && showMappingToggles ? ["use_team_mappings"] : []),
+ ...(teamFieldsVisible ? ["team_ids_jwt_field"] : []),
+ ];
+};
+
+export const pickMountedSSOValues = (values: SSOSettingsFormValues, variant: SSOFormVariant): SSOSettingsFormValues =>
+ Object.fromEntries(
+ mountedSSOFieldNames(values, variant).map((name) => [name, values[name as keyof SSOSettingsFormValues]]),
+ );
+
+export const submitMountedSSOValues =
+ (
+ form: UseFormReturn,
+ variant: SSOFormVariant,
+ onFormSubmit: (formValues: SSOSettingsFormValues) => Promise | void,
+ ) =>
+ () =>
+ void form.handleSubmit((values) => onFormSubmit(pickMountedSSOValues(values, variant)))();
+
+const REQUIRED_MESSAGES: Record = {
+ sso_provider: "Please select an SSO provider",
+ user_email: "Please enter the email of the proxy admin",
+ proxy_base_url: "Please enter the proxy base url",
+ group_claim: "Please enter the group claim",
+ team_ids_jwt_field: "Please enter the team IDs JWT field",
+};
+
+const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
+
+export const buildSSOSettingsSchema = (variant: SSOFormVariant) =>
+ z.custom().superRefine((values, ctx) => {
+ const mounted = new Set(mountedSSOFieldNames(values, variant));
+
+ const requireField = (name: string) => {
+ if (mounted.has(name) && isBlank(values[name as keyof SSOSettingsFormValues])) {
+ ctx.addIssue({ code: "custom", path: [name], message: REQUIRED_MESSAGES[name] });
+ }
+ };
+
+ requireField("sso_provider");
+ requireField("user_email");
+ requireField("group_claim");
+ requireField("team_ids_jwt_field");
+
+ const providerConfig = values.sso_provider ? ssoProviderConfigs[values.sso_provider] : undefined;
+ providerConfig?.fields.forEach((field) => {
+ if (field.required === false) return;
+ if (!isBlank(values[field.name])) return;
+ ctx.addIssue({
+ code: "custom",
+ path: [field.name],
+ message: `Please enter the ${field.label.toLowerCase()}`,
+ });
+ });
+
+ const proxyBaseUrl = values.proxy_base_url;
+ if (isBlank(proxyBaseUrl)) {
+ ctx.addIssue({ code: "custom", path: ["proxy_base_url"], message: REQUIRED_MESSAGES.proxy_base_url });
+ return;
+ }
+ if (!/^https?:\/\/.+/.test(proxyBaseUrl as string)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["proxy_base_url"],
+ message: "URL must start with http:// or https://",
+ });
+ return;
+ }
+ if ((proxyBaseUrl as string).endsWith("/")) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["proxy_base_url"],
+ message: "URL must not end with a trailing slash",
+ });
+ }
+ });
+
+export const emptySSOSettingsFormValues: SSOSettingsFormValues = {
+ sso_provider: "",
+ google_client_id: "",
+ google_client_secret: "",
+ microsoft_client_id: "",
+ microsoft_client_secret: "",
+ microsoft_tenant: "",
+ generic_client_id: "",
+ generic_client_secret: "",
+ generic_authorization_endpoint: "",
+ generic_token_endpoint: "",
+ generic_userinfo_endpoint: "",
+ user_email: "",
+ proxy_base_url: "",
+ default_role: "internal_user",
+};
+
+export const useSSOSettingsForm = (
+ variant: SSOFormVariant,
+ values?: SSOSettingsFormValues,
+): UseFormReturn =>
+ useZodForm(buildSSOSettingsSchema(variant), {
+ mode: "onChange",
+ defaultValues: emptySSOSettingsFormValues,
+ ...(values ? { values } : {}),
+ });
+
+const SSOProviderField = ({ field }: { field: SSOProviderConfig["fields"][number] }) => {
+ const { control } = useFormContext();
+
+ if (field.type === "checkbox") {
+ return (
+
+ {({ value, onChange, onBlur, id, ...rest }) => (
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {({ ref, value, ...rest }) => {
+ const shared = { placeholder: field.placeholder, value: (value as string) ?? "", ...rest };
+ if (field.type === "textarea") return ;
+ if (field.type === "password" || field.name.includes("client")) return ;
+ return ;
+ }}
+
+ );
+};
+
export const renderProviderFields = (provider: string) => {
const config = ssoProviderConfigs[provider];
if (!config) return null;
- return config.fields.map((field) => {
- const isRequired = field.required !== false;
- const rules = isRequired ? [{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }] : [];
- let control: React.ReactNode;
- if (field.type === "checkbox") {
- control = ;
- } else if (field.type === "textarea") {
- control = ;
- } else if (field.type === "password" || field.name.includes("client")) {
- control = ;
- } else {
- control = ;
- }
- return (
-
- {control}
-
- );
- });
+ return config.fields.map((field) => );
};
-const BaseSSOSettingsForm: React.FC = ({ form, onFormSubmit }) => {
+export const SSOProviderSelectField = () => {
+ const { control } = useFormContext();
+
return (
-
-
-
- {Object.entries(ssoProviderLogoMap).map(([value, logo]) => (
-
-
+
+ {({ value, onChange, onBlur, id, ...rest }) => (
+
+
+ {(provider: string) => (provider ? providerOptionLabel(provider) : "")}
+
+
+ {Object.entries(ssoProviderLogoMap).map(([optionValue, logo]) => (
+
+
{logo && (
)}
-
- {ssoProviderDisplayNames[value] || value.charAt(0).toUpperCase() + value.slice(1) + " SSO"}
-
-
-
+ {providerOptionLabel(optionValue)}
+
+
))}
+
+
+ )}
+
+ );
+};
+
+export const ProxyAdminEmailField = () => {
+ const { control } = useFormContext();
+
+ return (
+
+ {({ ref, value, ...rest }) => }
+
+ );
+};
+
+export const ProxyBaseUrlField = () => {
+ const { control } = useFormContext();
+
+ return (
+
+ {({ ref, value, onChange, ...rest }) => (
+ onChange(event.target.value.trim())}
+ {...rest}
+ />
+ )}
+
+ );
+};
+
+export const MappingToggleField = ({
+ name,
+ label,
+}: {
+ name: "use_role_mappings" | "use_team_mappings";
+ label: string;
+}) => {
+ const { control } = useFormContext();
+
+ return (
+
+ {({ value, onChange, onBlur, id, ...rest }) => (
+
+ )}
+
+ );
+};
+
+export const GroupClaimField = () => {
+ const { control } = useFormContext();
+
+ return (
+
+ {({ ref, value, ...rest }) => }
+
+ );
+};
+
+const DEFAULT_ROLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
+ { value: "internal_user_viewer", label: "Internal Viewer" },
+ { value: "internal_user", label: "Internal User" },
+ { value: "proxy_admin_viewer", label: "Admin Viewer" },
+ { value: "proxy_admin", label: "Proxy Admin" },
+];
+
+const providerOptionLabel = (value: string) =>
+ ssoProviderDisplayNames[value] || value.charAt(0).toUpperCase() + value.slice(1) + " SSO";
+
+export const RoleMappingTeamFields = () => {
+ const { control } = useFormContext();
+
+ return (
+ <>
+
+ {({ value, onChange, onBlur, id, ...rest }) => (
+
+
+
+ {(role: string) => DEFAULT_ROLE_OPTIONS.find((option) => option.value === role)?.label ?? role}
+
+
+
+ {DEFAULT_ROLE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
-
+ )}
+
-
prevValues.sso_provider !== currentValues.sso_provider}
- >
- {({ getFieldValue }) => {
- const provider = getFieldValue("sso_provider");
- return provider ? renderProviderFields(provider) : null;
+
+ {({ ref, value, ...rest }) => }
+
+
+
+ {({ ref, value, ...rest }) => }
+
+
+
+ {({ ref, value, ...rest }) => }
+
+
+
+ {({ ref, value, ...rest }) => }
+
+ >
+ );
+};
+
+export const TeamIdsJwtFieldField = () => {
+ const { control } = useFormContext();
+
+ return (
+
+ {({ ref, value, ...rest }) => }
+
+ );
+};
+
+const BaseSSOSettingsForm: React.FC = ({ form, onFormSubmit }) => {
+ const provider = useWatch({ control: form.control, name: "sso_provider" });
+ const useRoleMappings = useWatch({ control: form.control, name: "use_role_mappings" });
+ const useTeamMappings = useWatch({ control: form.control, name: "use_team_mappings" });
+ const showMappingToggles = supportsMappings(provider);
+
+ return (
+
+
+
-
-
- value?.trim()}
- rules={[
- { required: true, message: "Please enter the proxy base url" },
- {
- pattern: /^https?:\/\/.+/,
- message: "URL must start with http:// or https://",
- },
- {
- validator: (_, value) => {
- // Only check for trailing slash if the URL starts with http:// or https://
- if (value && /^https?:\/\/.+/.test(value) && value.endsWith("/")) {
- return Promise.reject("URL must not end with a trailing slash");
- }
- return Promise.resolve();
- },
- },
- ]}
- >
-
-
-
- prevValues.sso_provider !== currentValues.sso_provider}
- >
- {({ getFieldValue }) => {
- const provider = getFieldValue("sso_provider");
- return provider === "okta" || provider === "generic" ? (
-
-
-
- ) : null;
- }}
-
-
-
- prevValues.use_role_mappings !== currentValues.use_role_mappings ||
- prevValues.sso_provider !== currentValues.sso_provider
- }
- >
- {({ getFieldValue }) => {
- const useRoleMappings = getFieldValue("use_role_mappings");
- const provider = getFieldValue("sso_provider");
- const supportsRoleMappings = provider === "okta" || provider === "generic";
- return useRoleMappings && supportsRoleMappings ? (
-
-
-
- ) : null;
- }}
-
-
-
- prevValues.use_role_mappings !== currentValues.use_role_mappings ||
- prevValues.sso_provider !== currentValues.sso_provider
- }
- >
- {({ getFieldValue }) => {
- const useRoleMappings = getFieldValue("use_role_mappings");
- const provider = getFieldValue("sso_provider");
- const supportsRoleMappings = provider === "okta" || provider === "generic";
- return useRoleMappings && supportsRoleMappings ? (
+
+
+ {provider ? renderProviderFields(provider) : null}
+
+
+ {showMappingToggles && }
+ {useRoleMappings && showMappingToggles && (
<>
-
-
- Internal Viewer
- Internal User
- Admin Viewer
- Proxy Admin
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
>
- ) : null;
- }}
-
-
- prevValues.sso_provider !== currentValues.sso_provider}
- >
- {({ getFieldValue }) => {
- const provider = getFieldValue("sso_provider");
- return provider === "okta" || provider === "generic" ? (
-
-
-
- ) : null;
- }}
-
-
-
- prevValues.use_team_mappings !== currentValues.use_team_mappings ||
- prevValues.sso_provider !== currentValues.sso_provider
- }
- >
- {({ getFieldValue }) => {
- const useTeamMappings = getFieldValue("use_team_mappings");
- const provider = getFieldValue("sso_provider");
- const supportsTeamMappings = provider === "okta" || provider === "generic";
- return useTeamMappings && supportsTeamMappings ? (
-
-
-
- ) : null;
- }}
-
-
+ )}
+ {showMappingToggles && }
+ {useTeamMappings && showMappingToggles && }
+
+
+
);
};
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.integration.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.integration.test.tsx
new file mode 100644
index 00000000000..cfc99c0fa76
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.integration.test.tsx
@@ -0,0 +1,69 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../../../../../../tests/test-utils";
+import EditSSOSettingsModal from "./EditSSOSettingsModal";
+import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
+import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
+
+vi.mock("@/app/(dashboard)/hooks/sso/useSSOSettings", () => ({ useSSOSettings: vi.fn() }));
+vi.mock("@/app/(dashboard)/hooks/sso/useEditSSOSettings", () => ({ useEditSSOSettings: vi.fn() }));
+
+const mutateAsync = vi.fn().mockResolvedValue({});
+
+const storedGoogleConfig = {
+ google_client_id: "stored-client-id",
+ google_client_secret: "stored-client-secret",
+ user_email: "admin@example.com",
+ proxy_base_url: "https://gateway.example.com",
+};
+
+const seed = (values: Record = storedGoogleConfig) => {
+ vi.mocked(useSSOSettings).mockReturnValue({
+ data: { values },
+ isLoading: false,
+ error: null,
+ } as unknown as ReturnType);
+ vi.mocked(useEditSSOSettings).mockReturnValue({
+ mutateAsync,
+ isPending: false,
+ } as unknown as ReturnType);
+};
+
+const saveButton = () => screen.getByRole("button", { name: "Save" });
+
+describe("EditSSOSettingsModal (real form tree)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ seed();
+ });
+
+ it("blocks the save when a required credential is cleared", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders( );
+
+ await user.clear(await screen.findByLabelText("Google Client ID"));
+ await user.click(saveButton());
+
+ expect(await screen.findByText("Please enter the google client id")).toBeInTheDocument();
+ expect(mutateAsync).not.toHaveBeenCalled();
+ });
+
+ it("sends only the mounted fields when the form is valid", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders( );
+
+ await user.clear(await screen.findByLabelText("Google Client ID"));
+ await user.type(screen.getByLabelText("Google Client ID"), "rotated-client-id");
+ await user.click(saveButton());
+
+ await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
+ expect(mutateAsync.mock.calls[0][0]).toEqual({
+ sso_provider: "google",
+ google_client_id: "rotated-client-id",
+ google_client_secret: "stored-client-secret",
+ user_email: "admin@example.com",
+ proxy_base_url: "https://gateway.example.com",
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
index 664d7cc5373..0e289c12c5b 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx
@@ -1,6 +1,8 @@
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
-import EditSSOSettingsModal from "./EditSSOSettingsModal";
+import EditSSOSettingsModal, { toSSOFormValues } from "./EditSSOSettingsModal";
+import { ssoProviderConfigs } from "./BaseSSOSettingsForm";
+import type { SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
import { toast } from "@/lib/toast";
@@ -35,14 +37,6 @@ const TEST_IDS = {
TRIGGER_FORM_SUBMIT: "trigger-form-submit",
} as const;
-// Mock form instance
-const mockForm = {
- resetFields: vi.fn(),
- setFieldsValue: vi.fn(),
- getFieldsValue: vi.fn(),
- submit: vi.fn(),
-};
-
// Types
type SSOData = {
values: Record;
@@ -129,6 +123,8 @@ const createMockHooks = (): {
},
});
+let lastSeededForm: any;
+
vi.mock("antd", () => ({
Modal: ({ children, open, title, footer, onCancel, width, ...props }: any) => (
@@ -137,29 +133,20 @@ vi.mock("antd", () => ({
),
- Button: ({ children, onClick, loading, disabled, ...props }: any) => (
-
- {children}
-
- ),
- Form: {
- useForm: () => [mockForm],
- },
- Space: ({ children, ...props }: any) => (
-
- {children}
-
- ),
}));
-vi.mock("./BaseSSOSettingsForm", () => ({
- default: ({ form, onFormSubmit }: any) => (
-
- onFormSubmit({ testField: "testValue" })}>
- Trigger Form Submit
-
-
- ),
+vi.mock("./BaseSSOSettingsForm", async (importOriginal) => ({
+ ...(await importOriginal()),
+ default: ({ form, onFormSubmit }: any) => {
+ lastSeededForm = form;
+ return (
+
+ onFormSubmit({ testField: "testValue" })}>
+ Trigger Form Submit
+
+
+ );
+ },
}));
vi.mock("@/app/(dashboard)/hooks/sso/useSSOSettings", () => ({
@@ -212,10 +199,12 @@ const renderComponent = (props: Partial screen.getAllByTestId(TEST_IDS.BUTTON);
+const getButtons = () => within(screen.getByTestId("modal-footer")).getAllByRole("button");
const getCancelButton = () => getButtons()[0];
const getSaveButton = () => getButtons()[1];
+const seededValuesFor = (ssoData: SSOData) => toSSOFormValues(ssoData.values as SSOSettingsValues);
+
describe("EditSSOSettingsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -259,16 +248,24 @@ describe("EditSSOSettingsModal", () => {
fireEvent.click(getCancelButton());
- expect(mockForm.resetFields).toHaveBeenCalled();
expect(mockOnCancel).toHaveBeenCalled();
});
- it("calls form.submit when save button is clicked", () => {
+ it("calls form.submit when save button is clicked", async () => {
+ const mockMutateAsync = vi.fn().mockResolvedValue({ success: true });
+ (processSSOSettingsPayload as any).mockReturnValue({ processed: "payload" });
+ setupMocks({
+ useSSOSettings: { data: createGoogleSSOData({ proxy_base_url: "https://proxy.example.com" }) },
+ useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
+ });
+
renderComponent();
fireEvent.click(getSaveButton());
- expect(mockForm.submit).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(mockMutateAsync).toHaveBeenCalled();
+ });
});
describe("Loading States", () => {
@@ -289,7 +286,7 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
- expect(getSaveButton()).toHaveAttribute("data-loading", "true");
+ expect(getSaveButton()).toBeDisabled();
expect(getSaveButton()).toHaveTextContent(TEST_DATA.BUTTON_TEXT.SAVING);
});
});
@@ -378,9 +375,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: expectedProvider,
- ...ssoData.values,
});
});
});
@@ -420,9 +416,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: SSO_PROVIDERS.GOOGLE,
- ...ssoData.values,
use_role_mappings: true,
group_claim: "groups",
default_role: "internal_user",
@@ -448,9 +443,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: SSO_PROVIDERS.GOOGLE,
- ...ssoData.values,
use_role_mappings: true,
group_claim: "groups",
default_role: "internal_user",
@@ -474,8 +468,7 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.resetFields).toHaveBeenCalled();
- expect(mockForm.setFieldsValue).toHaveBeenCalled();
+ expect(seededValuesFor(ssoData).sso_provider).toBe(SSO_PROVIDERS.GOOGLE);
});
});
@@ -488,7 +481,7 @@ describe("EditSSOSettingsModal", () => {
renderComponent({ isVisible: false });
- expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
+ expect(screen.getByTestId(TEST_IDS.MODAL)).toHaveAttribute("data-open", "false");
});
it("skips initialization when SSO data is unavailable", () => {
@@ -498,7 +491,7 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
- expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
+ expect(screen.getByTestId(TEST_IDS.BASE_SSO_FORM)).toBeInTheDocument();
});
});
});
@@ -564,9 +557,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: SSO_PROVIDERS.GOOGLE,
- ...ssoData.values,
use_role_mappings: true,
group_claim: "groups",
default_role: "internal_user",
@@ -590,9 +582,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: SSO_PROVIDERS.GENERIC,
- ...ssoData.values,
use_team_mappings: true,
team_ids_jwt_field: "teams",
});
@@ -611,9 +602,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: SSO_PROVIDERS.GENERIC,
- ...ssoData.values,
use_team_mappings: true,
team_ids_jwt_field: "custom_teams_field",
});
@@ -644,9 +634,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: SSO_PROVIDERS.GENERIC,
- ...ssoData.values,
use_role_mappings: true,
group_claim: "groups",
default_role: "internal_user",
@@ -670,7 +659,7 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- const callArgs = mockForm.setFieldsValue.mock.calls[0][0];
+ const callArgs = seededValuesFor(ssoData);
expect(callArgs.use_team_mappings).toBeUndefined();
expect(callArgs.team_ids_jwt_field).toBeUndefined();
});
@@ -690,9 +679,8 @@ describe("EditSSOSettingsModal", () => {
renderComponent();
await waitFor(() => {
- expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
+ expect(seededValuesFor(ssoData)).toMatchObject({
sso_provider: SSO_PROVIDERS.GENERIC,
- ...ssoData.values,
});
});
});
@@ -715,4 +703,48 @@ describe("EditSSOSettingsModal", () => {
expect(processSSOSettingsPayload).toHaveBeenCalled();
});
});
+
+ describe("Reseeding", () => {
+ it("replaces every field when reopened against a different stored config", async () => {
+ const first = createGoogleSSOData({
+ google_client_id: "first-tenant-id",
+ proxy_base_url: "https://first.example.com",
+ user_email: "first-admin@example.com",
+ });
+ setupMocks({ useSSOSettings: { data: first, isLoading: false, error: null } });
+ const { rerender } = renderComponent();
+
+ await waitFor(() => {
+ expect(lastSeededForm.getValues().google_client_id).toBe("first-tenant-id");
+ });
+
+ const second = createGoogleSSOData({
+ google_client_id: "second-tenant-id",
+ proxy_base_url: "https://second.example.com",
+ user_email: "second-admin@example.com",
+ });
+ setupMocks({ useSSOSettings: { data: second, isLoading: false, error: null } });
+ rerender( );
+
+ await waitFor(() => {
+ expect(lastSeededForm.getValues().google_client_id).toBe("second-tenant-id");
+ });
+ expect(JSON.stringify(lastSeededForm.getValues())).not.toContain("first");
+ });
+ });
+
+ describe("Seeding completeness", () => {
+ it("seeds every field the provider forms can mount", () => {
+ const allFields = Object.values(ssoProviderConfigs).flatMap((config) => config.fields);
+ const textFieldNames = Array.from(
+ new Set(allFields.filter((field) => field.type !== "checkbox").map((field) => field.name)),
+ );
+ const stored = Object.fromEntries(textFieldNames.map((name) => [name, `stored-${name}`]));
+
+ const seeded = toSSOFormValues({ ...stored, saml_allow_unsolicited: "true" } as unknown as SSOSettingsValues);
+
+ expect(textFieldNames.filter((name) => seeded[name] !== `stored-${name}`)).toEqual([]);
+ expect(seeded.saml_allow_unsolicited).toBe(true);
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
index 3d968996037..364fb68fd91 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx
@@ -1,12 +1,19 @@
"use client";
-import { Button, Form, Modal, Space } from "antd";
-import React, { useEffect } from "react";
-import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
+import { Modal } from "antd";
+import React, { useMemo } from "react";
+import BaseSSOSettingsForm, {
+ emptySSOSettingsFormValues,
+ submitMountedSSOValues,
+ useSSOSettingsForm,
+ type SSOSettingsFormValues,
+} from "./BaseSSOSettingsForm";
+import { Button } from "@/components/ui/button";
+import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { toast } from "@/lib/toast";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { detectSSOProvider, processSSOSettingsPayload } from "../utils";
-import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
+import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
interface EditSSOSettingsModalProps {
@@ -15,31 +22,39 @@ interface EditSSOSettingsModalProps {
onSuccess: () => void;
}
-const EditSSOSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => {
- const [form] = Form.useForm();
+const joinTeams = (teams: string[] | undefined): string => {
+ if (!teams || teams.length === 0) return "";
+ return teams.join(", ");
+};
- // Use react-query hooks for SSO settings
- const ssoSettings = useSSOSettings();
- const { mutateAsync, isPending } = useEditSSOSettings();
- useEffect(() => {
- if (isVisible && ssoSettings.data && ssoSettings.data.values) {
- const ssoData = ssoSettings.data;
+export const toSSOFormValues = (values: SSOSettingsValues): SSOSettingsFormValues => {
+ const roleMappings = values.role_mappings;
+ const teamMappings = values.team_mappings;
- // Determine which SSO provider is configured
- const selectedProvider = detectSSOProvider(ssoData.values);
-
- // Extract role mappings if they exist
- let roleMappingFields = {};
- if (ssoData.values.role_mappings) {
- const roleMappings = ssoData.values.role_mappings;
-
- // Helper function to join arrays into comma-separated strings
- const joinTeams = (teams: string[] | undefined): string => {
- if (!teams || teams.length === 0) return "";
- return teams.join(", ");
- };
-
- roleMappingFields = {
+ return {
+ ...emptySSOSettingsFormValues,
+ sso_provider: detectSSOProvider(values) ?? "",
+ google_client_id: values.google_client_id ?? "",
+ google_client_secret: values.google_client_secret ?? "",
+ microsoft_client_id: values.microsoft_client_id ?? "",
+ microsoft_client_secret: values.microsoft_client_secret ?? "",
+ microsoft_tenant: values.microsoft_tenant ?? "",
+ generic_client_id: values.generic_client_id ?? "",
+ generic_client_secret: values.generic_client_secret ?? "",
+ generic_authorization_endpoint: values.generic_authorization_endpoint ?? "",
+ generic_token_endpoint: values.generic_token_endpoint ?? "",
+ generic_userinfo_endpoint: values.generic_userinfo_endpoint ?? "",
+ generic_scope: values.generic_scope ?? undefined,
+ saml_idp_metadata_url: values.saml_idp_metadata_url ?? undefined,
+ saml_idp_metadata_xml: values.saml_idp_metadata_xml ?? undefined,
+ saml_sp_entity_id: values.saml_sp_entity_id ?? undefined,
+ user_email: values.user_email ?? "",
+ proxy_base_url: values.proxy_base_url ?? "",
+ ...(values.saml_allow_unsolicited != null
+ ? { saml_allow_unsolicited: values.saml_allow_unsolicited === "true" }
+ : {}),
+ ...(roleMappings
+ ? {
use_role_mappings: true,
group_claim: roleMappings.group_claim,
default_role: roleMappings.default_role || "internal_user",
@@ -47,40 +62,29 @@ const EditSSOSettingsModal: React.FC = ({ isVisible,
admin_viewer_teams: joinTeams(roleMappings.roles?.proxy_admin_viewer),
internal_user_teams: joinTeams(roleMappings.roles?.internal_user),
internal_viewer_teams: joinTeams(roleMappings.roles?.internal_user_viewer),
- };
- }
-
- // Extract team mappings if they exist
- let teamMappingFields = {};
- if (ssoData.values.team_mappings) {
- const teamMappings = ssoData.values.team_mappings;
- teamMappingFields = {
+ }
+ : {}),
+ ...(teamMappings
+ ? {
use_team_mappings: true,
team_ids_jwt_field: teamMappings.team_ids_jwt_field,
- };
- }
+ }
+ : {}),
+ };
+};
- // Set form values with existing data (excluding UI access control fields)
- const formValues = {
- sso_provider: selectedProvider,
- ...ssoData.values,
- ...roleMappingFields,
- ...teamMappingFields,
- ...(ssoData.values.saml_allow_unsolicited != null
- ? { saml_allow_unsolicited: ssoData.values.saml_allow_unsolicited === "true" }
- : {}),
- };
+const EditSSOSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => {
+ const ssoSettings = useSSOSettings();
+ const { mutateAsync, isPending } = useEditSSOSettings();
- // Clear form first, then set values with a small delay to ensure proper initialization
- form.resetFields();
- setTimeout(() => {
- form.setFieldsValue(formValues);
- }, 100);
- }
- }, [isVisible, ssoSettings.data, form]);
+ const seededValues = useMemo(
+ () => (ssoSettings.data?.values ? toSSOFormValues(ssoSettings.data.values) : emptySSOSettingsFormValues),
+ [ssoSettings.data],
+ );
- // Enhanced form submission handler
- const handleFormSubmit = async (formValues: Record) => {
+ const form = useSSOSettingsForm("sso-settings", seededValues);
+
+ const handleFormSubmit = async (formValues: SSOSettingsFormValues) => {
try {
const payload = processSSOSettingsPayload(formValues);
@@ -94,13 +98,12 @@ const EditSSOSettingsModal: React.FC = ({ isVisible,
},
});
} catch (error) {
- // Handle processing errors gracefully
toast.fromError("Failed to process SSO settings: " + parseErrorMessage(error));
}
};
const handleCancel = () => {
- form.resetFields();
+ form.reset(seededValues);
onCancel();
};
@@ -110,14 +113,19 @@ const EditSSOSettingsModal: React.FC = ({ isVisible,
open={isVisible}
width={800}
footer={
-
-
+
+
Cancel
- form.submit()}>
+
+ {isPending && }
{isPending ? "Saving..." : "Save"}
-
+
}
onCancel={handleCancel}
>