feat(ui): migrate standalone modals to shadcn

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-24 12:28:03 +00:00
parent 75e172b0e0
commit 9028e69e76
No known key found for this signature in database
8 changed files with 1309 additions and 971 deletions

View file

@ -109,6 +109,7 @@
"src/components/permissions/AgentPermissions.tsx",
"src/components/permissions/MCPServerPermissions.tsx",
"src/components/survey/ClaudeCodeModal.tsx",
"src/components/survey/SurveyModal.tsx",
"src/components/view_logs/ToolsSection/ToolItem.tsx",
"src/components/guardrails/content_filter/PatternTable.tsx",
"src/components/guardrails/content_filter/ContentFilterDisplay.tsx",

View file

@ -31,6 +31,16 @@ stay on antd for phase 1 and will be addressed in a targeted follow-up.
with Switch/InputNumber/Select controls. Defer until shared section
unblocks.
- `src/components/add_pass_through.tsx`: same dependency chain.
- `src/components/OldTeams.tsx` (1577 LoC): legacy teams page that mirrors
`team/TeamInfo.tsx` in structure — deeply coupled antd `Form` with model /
guardrail multi-selects, `Form.List`-style panels, antd `Pagination` + `Table`
driven by server-side sort, antd `Tabs` container, and Tremor `Accordion`
sections for router / logging / advanced settings. Same migration surface
and blockers as TeamInfo (still-required Tremor `Accordion`, wide antd Form
context consumed by several common_components). Defer until the TeamInfo
blocker clears — these two pages must be migrated together or the shared
form-surface common_components (ModelAliasManager, PremiumLoggingSettings,
RouterSettingsAccordion) will be in an inconsistent state.
- `src/components/team/TeamInfo.tsx` (1724 LoC): team detail/edit page with a
deeply coupled antd `Form` — `Form.List` + inline Promise validators for
per-model rate limits, `Form.useWatch` hooks driving dependent rendering,

View file

@ -1,15 +1,12 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Form } from "antd";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import SSOModals from "./SSOModals";
// Mock the networking functions
vi.mock("./networking", () => ({
getSSOSettings: vi.fn(),
updateSSOSettings: vi.fn(),
}));
// Mock parseErrorMessage
vi.mock("./shared/errorUtils", () => ({
parseErrorMessage: vi.fn((error) => error?.message || "An error occurred"),
}));
@ -17,227 +14,204 @@ vi.mock("./shared/errorUtils", () => ({
import NotificationsManager from "./molecules/notifications_manager";
import { getSSOSettings, updateSSOSettings } from "./networking";
interface ExternalFormHandle {
resetFields: () => void;
setFieldsValue: (values: Record<string, unknown>) => void;
getFieldsValue: () => Record<string, unknown>;
}
function makeExternalForm(): ExternalFormHandle {
return {
resetFields: () => {},
setFieldsValue: () => {},
getFieldsValue: () => ({}),
};
}
/** shadcn `Select` renders a hidden native `<select>` for form submission and
* accessibility — use that to drive value changes reliably in jsdom. */
async function selectSSOProvider(trigger: HTMLElement, value: string) {
// The hidden select is the next sibling of the Radix trigger inside the
// wrapper. Locate it via the shared label association.
const hiddenSelect = trigger.parentElement?.querySelector("select");
if (!hiddenSelect) {
throw new Error("Could not find hidden native select element");
}
await act(async () => {
fireEvent.change(hiddenSelect, { target: { value } });
});
}
describe("SSOModals", () => {
it("should render the SSOModals component", () => {
const TestWrapper = () => {
const [form] = Form.useForm();
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken={null}
ssoConfigured={false}
/>
);
};
render(<TestWrapper />);
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken={null}
ssoConfigured={false}
/>,
);
expect(screen.getByText("Add SSO")).toBeInTheDocument();
});
it("should show validation error if proxy base url is not a valid URL", async () => {
const TestWrapper = () => {
const [form] = Form.useForm();
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken={null}
ssoConfigured={false}
/>
);
};
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken={null}
ssoConfigured={false}
/>,
);
render(<TestWrapper />);
const trigger = screen.getByLabelText("SSO Provider");
await selectSSOProvider(trigger, "google");
// Find and interact with the SSO provider select
const ssoProviderSelect = screen.getByLabelText("SSO Provider");
fireEvent.mouseDown(ssoProviderSelect);
// Wait for dropdown and select Google
await waitFor(() => {
const googleOption = screen.getByText("Google SSO");
fireEvent.click(googleOption);
const emailInput = screen.getByLabelText(/Proxy Admin Email/);
await act(async () => {
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
});
// Fill in the email field
const emailInput = screen.getByLabelText("Proxy Admin Email");
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
const urlInput = screen.getByLabelText(/Proxy Base URL/);
await act(async () => {
fireEvent.change(urlInput, { target: { value: "invalid-url" } });
});
// Fill in an invalid URL
const urlInput = screen.getByLabelText("Proxy Base URL");
fireEvent.change(urlInput, { target: { value: "invalid-url" } });
const saveButton = screen.getByRole("button", { name: "Save" });
await act(async () => {
fireEvent.click(saveButton);
});
// Submit the form
const saveButton = screen.getByText("Save");
fireEvent.click(saveButton);
// Check for validation error
await waitFor(
() => {
expect(screen.getByText("URL must start with http:// or https://")).toBeInTheDocument();
},
// The validation is based on a Promise, so we need to wait for it to resolve
{ timeout: 5000 },
);
});
it("should show validation error if proxy base url ends with trailing slash", async () => {
const TestWrapper = () => {
const [form] = Form.useForm();
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken={null}
ssoConfigured={false}
/>
);
};
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken={null}
ssoConfigured={false}
/>,
);
render(<TestWrapper />);
const trigger = screen.getByLabelText("SSO Provider");
await selectSSOProvider(trigger, "google");
// Find and interact with the SSO provider select
const ssoProviderSelect = screen.getByLabelText("SSO Provider");
fireEvent.mouseDown(ssoProviderSelect);
// Wait for dropdown and select Google
await waitFor(() => {
const googleOption = screen.getByText("Google SSO");
fireEvent.click(googleOption);
const emailInput = screen.getByLabelText(/Proxy Admin Email/);
await act(async () => {
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
});
// Fill in the email field
const emailInput = screen.getByLabelText("Proxy Admin Email");
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
const urlInput = screen.getByLabelText(/Proxy Base URL/) as HTMLInputElement;
await act(async () => {
fireEvent.change(urlInput, { target: { value: "https://example.com/" } });
});
// Fill in a URL with trailing slash
const urlInput = screen.getByLabelText("Proxy Base URL") as HTMLInputElement;
fireEvent.change(urlInput, { target: { value: "https://example.com/" } });
const saveButton = screen.getByRole("button", { name: "Save" });
await act(async () => {
fireEvent.click(saveButton);
});
// Submit the form
const saveButton = screen.getByText("Save");
fireEvent.click(saveButton);
// Check for validation error using findByText for async rendering
const errorMessage = await screen.findByText("URL must not end with a trailing slash", {}, { timeout: 5000 });
const errorMessage = await screen.findByText(
"URL must not end with a trailing slash",
{},
{ timeout: 5000 },
);
expect(errorMessage).toBeInTheDocument();
});
it("should allow typing https:// without interfering with slashes", async () => {
const TestWrapper = () => {
const [form] = Form.useForm();
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken={null}
ssoConfigured={false}
/>
);
};
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken={null}
ssoConfigured={false}
/>,
);
render(<TestWrapper />);
const urlInput = screen.getByLabelText("Proxy Base URL") as HTMLInputElement;
// Simulate user typing "https://"
fireEvent.change(urlInput, { target: { value: "h" } });
expect(urlInput.value).toBe("h");
fireEvent.change(urlInput, { target: { value: "ht" } });
expect(urlInput.value).toBe("ht");
fireEvent.change(urlInput, { target: { value: "http" } });
expect(urlInput.value).toBe("http");
fireEvent.change(urlInput, { target: { value: "https" } });
expect(urlInput.value).toBe("https");
fireEvent.change(urlInput, { target: { value: "https:" } });
expect(urlInput.value).toBe("https:");
fireEvent.change(urlInput, { target: { value: "https:/" } });
expect(urlInput.value).toBe("https:/");
fireEvent.change(urlInput, { target: { value: "https://" } });
expect(urlInput.value).toBe("https://");
// Continue typing the domain
fireEvent.change(urlInput, { target: { value: "https://example.com" } });
expect(urlInput.value).toBe("https://example.com");
const urlInput = screen.getByLabelText(/Proxy Base URL/) as HTMLInputElement;
const steps = ["h", "ht", "http", "https", "https:", "https:/", "https://", "https://example.com"];
for (const v of steps) {
await act(async () => {
fireEvent.change(urlInput, { target: { value: v } });
});
expect(urlInput.value).toBe(v);
}
});
it("should only show URL format error for incomplete URLs, not trailing slash error", async () => {
const TestWrapper = () => {
const [form] = Form.useForm();
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken={null}
ssoConfigured={false}
/>
);
};
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken={null}
ssoConfigured={false}
/>,
);
render(<TestWrapper />);
const trigger = screen.getByLabelText("SSO Provider");
await selectSSOProvider(trigger, "google");
// Find and interact with the SSO provider select
const ssoProviderSelect = screen.getByLabelText("SSO Provider");
fireEvent.mouseDown(ssoProviderSelect);
// Wait for dropdown and select Google
await waitFor(() => {
const googleOption = screen.getByText("Google SSO");
fireEvent.click(googleOption);
const emailInput = screen.getByLabelText(/Proxy Admin Email/);
await act(async () => {
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
});
// Fill in the email field
const emailInput = screen.getByLabelText("Proxy Admin Email");
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
const urlInput = screen.getByLabelText(/Proxy Base URL/);
await act(async () => {
fireEvent.change(urlInput, { target: { value: "http:" } });
});
// Fill in an incomplete URL like "http:"
const urlInput = screen.getByLabelText("Proxy Base URL");
fireEvent.change(urlInput, { target: { value: "http:" } });
const saveButton = screen.getByRole("button", { name: "Save" });
await act(async () => {
fireEvent.click(saveButton);
});
// Submit the form
const saveButton = screen.getByText("Save");
fireEvent.click(saveButton);
// Check that only the URL format error appears (use findByText for async rendering)
const errorMessage = await screen.findByText("URL must start with http:// or https://", {}, { timeout: 3000 });
const errorMessage = await screen.findByText(
"URL must start with http:// or https://",
{},
{ timeout: 3000 },
);
expect(errorMessage).toBeInTheDocument();
// Verify the trailing slash error does NOT appear
expect(screen.queryByText("URL must not end with a trailing slash")).not.toBeInTheDocument();
});
@ -263,128 +237,131 @@ describe("SSOModals", () => {
(getSSOSettings as any).mockResolvedValue(mockSSOData);
const TestWrapper = () => {
const [form] = Form.useForm();
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken="test-token"
ssoConfigured={false}
/>,
);
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken="test-token"
ssoConfigured={false}
/>
);
};
render(<TestWrapper />);
// Wait for the useEffect to load data and populate form
await waitFor(() => {
expect(getSSOSettings).toHaveBeenCalledWith("test-token");
});
// Check that form fields are populated with loaded data
await waitFor(() => {
const emailInput = screen.getByLabelText("Proxy Admin Email") as HTMLInputElement;
const emailInput = screen.getByLabelText(/Proxy Admin Email/) as HTMLInputElement;
expect(emailInput.value).toBe("admin@example.com");
});
const urlInput = screen.getByLabelText("Proxy Base URL") as HTMLInputElement;
const urlInput = screen.getByLabelText(/Proxy Base URL/) as HTMLInputElement;
expect(urlInput.value).toBe("https://example.com");
// Check that role mappings are populated
const groupClaimInput = screen.getByLabelText("Group Claim") as HTMLInputElement;
const groupClaimInput = screen.getByLabelText(/Group Claim/) as HTMLInputElement;
expect(groupClaimInput.value).toBe("groups");
});
it("should submit form with role mappings enabled", async () => {
const mockHandleShowInstructions = vi.fn();
(updateSSOSettings as any).mockResolvedValue({});
// Mock getSSOSettings to return empty data so form starts clean
(getSSOSettings as any).mockResolvedValue({ values: {} });
let formInstance: any = null;
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={mockHandleShowInstructions}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken="test-token"
ssoConfigured={false}
/>,
);
const TestWrapper = () => {
const [form] = Form.useForm();
formInstance = form;
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={() => {}}
handleAddSSOCancel={() => {}}
handleShowInstructions={mockHandleShowInstructions}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken="test-token"
ssoConfigured={false}
/>
);
};
render(<TestWrapper />);
// Wait for any initial loading to complete
await waitFor(() => {
expect(getSSOSettings).toHaveBeenCalledWith("test-token");
});
// Set the provider directly using the form to trigger conditional rendering
formInstance.setFieldsValue({ sso_provider: "okta" });
const providerTrigger = screen.getByLabelText("SSO Provider");
await selectSSOProvider(providerTrigger, "okta");
// Wait for the "Use Role Mappings" checkbox to appear
await waitFor(() => {
expect(screen.getByLabelText("Use Role Mappings")).toBeInTheDocument();
});
// Enable role mappings
const roleMappingsCheckbox = screen.getByLabelText("Use Role Mappings");
fireEvent.click(roleMappingsCheckbox);
await act(async () => {
fireEvent.click(roleMappingsCheckbox);
});
// Fill required fields
const emailInput = screen.getByLabelText("Proxy Admin Email");
fireEvent.change(emailInput, { target: { value: "admin@example.com" } });
const emailInput = screen.getByLabelText(/Proxy Admin Email/);
await act(async () => {
fireEvent.change(emailInput, { target: { value: "admin@example.com" } });
});
const urlInput = screen.getByLabelText("Proxy Base URL");
fireEvent.change(urlInput, { target: { value: "https://example.com" } });
const urlInput = screen.getByLabelText(/Proxy Base URL/);
await act(async () => {
fireEvent.change(urlInput, { target: { value: "https://example.com" } });
});
// Fill Okta specific fields
const clientIdInput = screen.getByLabelText("Generic Client ID");
fireEvent.change(clientIdInput, { target: { value: "test-client-id" } });
const clientIdInput = screen.getByLabelText(/Generic Client ID/);
await act(async () => {
fireEvent.change(clientIdInput, { target: { value: "test-client-id" } });
});
const clientSecretInput = screen.getByLabelText("Generic Client Secret");
fireEvent.change(clientSecretInput, { target: { value: "test-client-secret" } });
const clientSecretInput = screen.getByLabelText(/Generic Client Secret/);
await act(async () => {
fireEvent.change(clientSecretInput, { target: { value: "test-client-secret" } });
});
const authEndpointInput = screen.getByLabelText("Authorization Endpoint");
fireEvent.change(authEndpointInput, { target: { value: "https://example.okta.com/authorize" } });
const authEndpointInput = screen.getByLabelText(/Authorization Endpoint/);
await act(async () => {
fireEvent.change(authEndpointInput, {
target: { value: "https://example.okta.com/authorize" },
});
});
const tokenEndpointInput = screen.getByLabelText("Token Endpoint");
fireEvent.change(tokenEndpointInput, { target: { value: "https://example.okta.com/token" } });
const tokenEndpointInput = screen.getByLabelText(/Token Endpoint/);
await act(async () => {
fireEvent.change(tokenEndpointInput, {
target: { value: "https://example.okta.com/token" },
});
});
const userinfoEndpointInput = screen.getByLabelText("Userinfo Endpoint");
fireEvent.change(userinfoEndpointInput, { target: { value: "https://example.okta.com/userinfo" } });
const userinfoEndpointInput = screen.getByLabelText(/Userinfo Endpoint/);
await act(async () => {
fireEvent.change(userinfoEndpointInput, {
target: { value: "https://example.okta.com/userinfo" },
});
});
// Fill role mapping fields
const groupClaimInput = screen.getByLabelText("Group Claim");
fireEvent.change(groupClaimInput, { target: { value: "groups" } });
const groupClaimInput = screen.getByLabelText(/Group Claim/);
await act(async () => {
fireEvent.change(groupClaimInput, { target: { value: "groups" } });
});
const proxyAdminTeamsInput = screen.getByLabelText("Proxy Admin Teams");
fireEvent.change(proxyAdminTeamsInput, { target: { value: "admin-group, super-admin" } });
const proxyAdminTeamsInput = screen.getByLabelText(/Proxy Admin Teams/);
await act(async () => {
fireEvent.change(proxyAdminTeamsInput, {
target: { value: "admin-group, super-admin" },
});
});
// Submit the form
const saveButton = screen.getByText("Save");
fireEvent.click(saveButton);
const saveButton = screen.getByRole("button", { name: "Save" });
await act(async () => {
fireEvent.click(saveButton);
});
// Verify the API was called with correct payload including role mappings
await waitFor(() => {
expect(updateSSOSettings).toHaveBeenCalledWith("test-token", {
sso_provider: "okta",
@ -415,41 +392,35 @@ describe("SSOModals", () => {
it("should show Clear button and clear SSO settings when configured", async () => {
const mockHandleAddSSOOk = vi.fn();
(updateSSOSettings as any).mockResolvedValue({});
(NotificationsManager.success as any).mockImplementation(() => {});
(NotificationsManager.success as any).mockImplementation?.(() => {});
const TestWrapper = () => {
const [form] = Form.useForm();
render(
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={mockHandleAddSSOOk}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={makeExternalForm()}
accessToken="test-token"
ssoConfigured={true}
/>,
);
return (
<SSOModals
isAddSSOModalVisible={true}
isInstructionsModalVisible={false}
handleAddSSOOk={mockHandleAddSSOOk}
handleAddSSOCancel={() => {}}
handleShowInstructions={() => {}}
handleInstructionsOk={() => {}}
handleInstructionsCancel={() => {}}
form={form}
accessToken="test-token"
ssoConfigured={true}
/>
);
};
render(<TestWrapper />);
// Check that Clear button is visible when SSO is configured
const clearButton = screen.getByText("Clear");
const clearButton = screen.getByRole("button", { name: "Clear" });
expect(clearButton).toBeInTheDocument();
// Click Clear button to open confirmation modal
fireEvent.click(clearButton);
await act(async () => {
fireEvent.click(clearButton);
});
// Confirm the clear action in the modal
const confirmButton = screen.getByText("Yes, Clear");
fireEvent.click(confirmButton);
const confirmButton = await screen.findByRole("button", { name: "Yes, Clear" });
await act(async () => {
fireEvent.click(confirmButton);
});
// Verify the clear API was called with null values
await waitFor(() => {
expect(updateSSOSettings).toHaveBeenCalledWith("test-token", {
google_client_id: null,

View file

@ -1,7 +1,23 @@
import React, { useEffect, useState } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Modal, Form, Input, Button as Button2, Select, Checkbox } from "antd";
import { Text, TextInput } from "@tremor/react";
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getSSOSettings, updateSSOSettings } from "./networking";
import NotificationsManager from "./molecules/notifications_manager";
import { parseErrorMessage } from "./shared/errorUtils";
@ -14,9 +30,15 @@ interface SSOModalsProps {
handleShowInstructions: (formValues: Record<string, any>) => void;
handleInstructionsOk: () => void;
handleInstructionsCancel: () => void;
form: any; // Replace with proper Form type if available
/**
* Optional external form handle. For backwards compatibility callers may pass
* an antd `FormInstance` (from `Form.useForm()`); we adapt its common
* methods (`resetFields`, `setFieldsValue`, `getFieldsValue`) to the
* internal `react-hook-form` state. New callers may omit this entirely.
*/
form?: any;
accessToken: string | null;
ssoConfigured?: boolean; // Add optional prop to indicate if SSO is configured
ssoConfigured?: boolean;
}
const ssoProviderLogoMap: Record<string, string> = {
@ -26,7 +48,6 @@ const ssoProviderLogoMap: Record<string, string> = {
generic: "",
};
// Define the SSO provider configuration type
interface SSOProviderConfig {
envVarMap: Record<string, string>;
fields: Array<{
@ -36,7 +57,6 @@ interface SSOProviderConfig {
}>;
}
// Define configurations for each SSO provider
const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
google: {
envVarMap: {
@ -102,6 +122,153 @@ const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
},
};
const providerFieldKeys: string[] = Object.values(ssoProviderConfigs).flatMap(
(cfg) => cfg.fields.map((f) => f.name),
);
interface SSOFormValues {
sso_provider: string;
proxy_base_url: string;
user_email: 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;
[key: string]: any;
}
const defaultFormValues: SSOFormValues = {
sso_provider: "",
proxy_base_url: "",
user_email: "",
use_role_mappings: false,
group_claim: "",
default_role: "internal_user",
proxy_admin_teams: "",
admin_viewer_teams: "",
internal_user_teams: "",
internal_viewer_teams: "",
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: "",
};
const URL_PATTERN = /^https?:\/\/.+/;
function ProviderFields({ provider }: { provider: string }) {
const { control } = useFormContext<SSOFormValues>();
const config = ssoProviderConfigs[provider];
if (!config) return null;
return (
<>
{config.fields.map((field) => {
const isClient = field.name.includes("client");
return (
<Controller
key={field.name}
control={control}
name={field.name as any}
rules={{ required: `Please enter the ${field.label.toLowerCase()}` }}
render={({ field: rhfField, fieldState }) => (
<div className="grid grid-cols-[1fr_2fr] gap-4 items-start">
<Label htmlFor={`sso-${field.name}`} className="mt-2 text-left">
{field.label} <span className="text-destructive">*</span>
</Label>
<div className="space-y-1">
<Input
id={`sso-${field.name}`}
type={isClient ? "password" : "text"}
placeholder={field.placeholder}
value={(rhfField.value as string) ?? ""}
onChange={rhfField.onChange}
onBlur={rhfField.onBlur}
name={rhfField.name}
ref={rhfField.ref}
/>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</div>
</div>
)}
/>
);
})}
</>
);
}
function RoleMappingFields() {
const { control, register } = useFormContext<SSOFormValues>();
return (
<>
<Controller
control={control}
name="group_claim"
rules={{ required: "Please enter the group claim" }}
render={({ field, fieldState }) => (
<div className="grid grid-cols-[1fr_2fr] gap-4 items-start">
<Label htmlFor="sso-group-claim" className="mt-2 text-left">
Group Claim <span className="text-destructive">*</span>
</Label>
<div className="space-y-1">
<Input id="sso-group-claim" value={field.value ?? ""} onChange={field.onChange} />
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</div>
</div>
)}
/>
<Controller
control={control}
name="default_role"
render={({ field }) => (
<div className="grid grid-cols-[1fr_2fr] gap-4 items-center">
<Label htmlFor="sso-default-role" className="text-left">
Default Role
</Label>
<Select value={(field.value as string) || "internal_user"} onValueChange={field.onChange}>
<SelectTrigger id="sso-default-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal_user_viewer">Internal Viewer</SelectItem>
<SelectItem value="internal_user">Internal User</SelectItem>
<SelectItem value="proxy_admin_viewer">Admin Viewer</SelectItem>
<SelectItem value="proxy_admin">Proxy Admin</SelectItem>
</SelectContent>
</Select>
</div>
)}
/>
{[
{ name: "proxy_admin_teams", label: "Proxy Admin Teams" },
{ name: "admin_viewer_teams", label: "Admin Viewer Teams" },
{ name: "internal_user_teams", label: "Internal User Teams" },
{ name: "internal_viewer_teams", label: "Internal Viewer Teams" },
].map((f) => (
<div key={f.name} className="grid grid-cols-[1fr_2fr] gap-4 items-center">
<Label htmlFor={`sso-${f.name}`} className="text-left">
{f.label}
</Label>
<Input id={`sso-${f.name}`} {...register(f.name as any)} />
</div>
))}
</>
);
}
const SSOModals: React.FC<SSOModalsProps> = ({
isAddSSOModalVisible,
isInstructionsModalVisible,
@ -110,31 +277,48 @@ const SSOModals: React.FC<SSOModalsProps> = ({
handleShowInstructions,
handleInstructionsOk,
handleInstructionsCancel,
form,
form: externalForm,
accessToken,
ssoConfigured = false, // Default to false if not provided
ssoConfigured = false,
}) => {
const [isClearConfirmModalVisible, setIsClearConfirmModalVisible] = useState(false);
const rhfForm = useForm<SSOFormValues>({
defaultValues: defaultFormValues,
mode: "onSubmit",
});
// Bridge the optional antd-style `form` prop to the internal RHF form, so
// existing callers that still do `Form.useForm()` and call `resetFields` /
// `setFieldsValue` on the instance keep working during the migration.
useEffect(() => {
if (!externalForm) return;
const patchedResetFields = () => rhfForm.reset(defaultFormValues);
const patchedSetFieldsValue = (values: Partial<SSOFormValues>) => {
Object.entries(values).forEach(([k, v]) => {
rhfForm.setValue(k as any, v as any, { shouldDirty: false });
});
};
const patchedGetFieldsValue = () => rhfForm.getValues();
externalForm.resetFields = patchedResetFields;
externalForm.setFieldsValue = patchedSetFieldsValue;
externalForm.getFieldsValue = patchedGetFieldsValue;
}, [externalForm, rhfForm]);
const provider = rhfForm.watch("sso_provider");
const useRoleMappings = rhfForm.watch("use_role_mappings");
// Load existing SSO settings when modal opens
useEffect(() => {
const loadSSOSettings = async () => {
if (isAddSSOModalVisible && accessToken) {
try {
const ssoData = await getSSOSettings(accessToken);
console.log("Raw SSO data received:", ssoData); // Debug log
if (ssoData && ssoData.values) {
console.log("SSO values:", ssoData.values); // Debug log
console.log("user_email from API:", ssoData.values.user_email); // Debug log
// Determine which SSO provider is configured
let selectedProvider = null;
let selectedProvider: string = "";
if (ssoData.values.google_client_id) {
selectedProvider = "google";
} else if (ssoData.values.microsoft_client_id) {
selectedProvider = "microsoft";
} else if (ssoData.values.generic_client_id) {
// Check if it looks like Okta based on endpoints
if (
ssoData.values.generic_authorization_endpoint?.includes("okta") ||
ssoData.values.generic_authorization_endpoint?.includes("auth0")
@ -145,12 +329,10 @@ const SSOModals: React.FC<SSOModalsProps> = ({
}
}
// Extract role mappings if they exist
let roleMappingFields = {};
let roleMappingFields: Partial<SSOFormValues> = {};
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(", ");
@ -167,23 +349,19 @@ const SSOModals: React.FC<SSOModalsProps> = ({
};
}
// Set form values with existing data (excluding UI access control fields)
const formValues = {
...defaultFormValues,
sso_provider: selectedProvider,
proxy_base_url: ssoData.values.proxy_base_url,
user_email: ssoData.values.user_email,
proxy_base_url: ssoData.values.proxy_base_url ?? "",
user_email: ssoData.values.user_email ?? "",
...ssoData.values,
...roleMappingFields,
};
console.log("Setting form values:", formValues); // Debug log
// Clear form first, then set values with a small delay to ensure proper initialization
form.resetFields();
setTimeout(() => {
form.setFieldsValue(formValues);
console.log("Form values set, current form values:", form.getFieldsValue()); // Debug log
}, 100);
rhfForm.reset({
...defaultFormValues,
...formValues,
});
}
} catch (error) {
console.error("Failed to load SSO settings:", error);
@ -192,10 +370,9 @@ const SSOModals: React.FC<SSOModalsProps> = ({
};
loadSSOSettings();
}, [isAddSSOModalVisible, accessToken, form]);
}, [isAddSSOModalVisible, accessToken, rhfForm]);
// Enhanced form submission handler
const handleFormSubmit = async (formValues: Record<string, any>) => {
const onSubmit = rhfForm.handleSubmit(async (formValues) => {
if (!accessToken) {
NotificationsManager.fromBackend("No access token available");
return;
@ -213,13 +390,27 @@ const SSOModals: React.FC<SSOModalsProps> = ({
...rest
} = formValues;
const payload: any = {
...rest,
// Strip fields that belong to providers other than the selected one, and
// drop any empty strings so we don't send blank fields to the backend.
const selectedConfig = ssoProviderConfigs[rest.sso_provider];
const allowedProviderKeys = new Set(
selectedConfig ? selectedConfig.fields.map((f) => f.name) : [],
);
const payload: Record<string, any> = {
sso_provider: rest.sso_provider,
user_email: rest.user_email,
proxy_base_url: rest.proxy_base_url,
};
for (const key of providerFieldKeys) {
if (allowedProviderKeys.has(key)) {
const v = (rest as any)[key];
if (v !== undefined && v !== null && v !== "") {
payload[key] = v;
}
}
}
// Add role mappings if use_role_mappings is checked
if (use_role_mappings) {
// Helper function to split comma-separated string into array
const splitTeams = (teams: string | undefined): string[] => {
if (!teams || teams.trim() === "") return [];
return teams
@ -228,7 +419,6 @@ const SSOModals: React.FC<SSOModalsProps> = ({
.filter((team) => team.length > 0);
};
// Map default role display values to backend values
const defaultRoleMapping: Record<string, string> = {
internal_user_viewer: "internal_user_viewer",
internal_user: "internal_user",
@ -249,17 +439,14 @@ const SSOModals: React.FC<SSOModalsProps> = ({
};
}
// Save SSO settings using the new API
await updateSSOSettings(accessToken, payload);
// Continue with the original flow (show instructions)
handleShowInstructions(formValues);
} catch (error: unknown) {
NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
}
};
});
// Handle clearing SSO settings
const handleClearSSO = async () => {
if (!accessToken) {
NotificationsManager.fromBackend("No access token available");
@ -267,7 +454,6 @@ const SSOModals: React.FC<SSOModalsProps> = ({
}
try {
// Clear all SSO settings
const clearSettings = {
google_client_id: null,
google_client_secret: null,
@ -287,13 +473,10 @@ const SSOModals: React.FC<SSOModalsProps> = ({
await updateSSOSettings(accessToken, clearSettings);
// Clear the form
form.resetFields();
rhfForm.reset(defaultFormValues);
// Close the confirmation modal
setIsClearConfirmModalVisible(false);
// Close the main SSO modal and trigger refresh
handleAddSSOOk();
NotificationsManager.success("SSO settings cleared successfully");
@ -303,258 +486,219 @@ const SSOModals: React.FC<SSOModalsProps> = ({
}
};
// Helper function to render provider fields
const renderProviderFields = (provider: string) => {
const config = ssoProviderConfigs[provider];
if (!config) return null;
return config.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
>
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
</Form.Item>
));
};
return (
<>
<Modal
title={ssoConfigured ? "Edit SSO Settings" : "Add SSO"}
<Dialog
open={isAddSSOModalVisible}
width={800}
footer={null}
onOk={handleAddSSOOk}
onCancel={handleAddSSOCancel}
>
<Form
form={form}
onFinish={handleFormSubmit}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item
label="SSO Provider"
name="sso_provider"
rules={[{ required: true, message: "Please select an SSO provider" }]}
>
<Select>
{Object.entries(ssoProviderLogoMap).map(([value, logo]) => (
<Select.Option key={value} value={value}>
<div style={{ display: "flex", alignItems: "center", padding: "4px 0" }}>
{logo && (
<img
src={logo}
alt={value}
style={{ height: 24, width: 24, marginRight: 12, objectFit: "contain" }}
/>
)}
<span>
{value.toLowerCase() === "okta"
? "Okta / Auth0"
: value.charAt(0).toUpperCase() + value.slice(1)}{" "}
SSO
</span>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.sso_provider !== currentValues.sso_provider}
>
{({ getFieldValue }) => {
const provider = getFieldValue("sso_provider");
return provider ? renderProviderFields(provider) : null;
}}
</Form.Item>
<Form.Item
label="Proxy Admin Email"
name="user_email"
rules={[{ required: true, message: "Please enter the email of the proxy admin" }]}
>
<TextInput />
</Form.Item>
<Form.Item
label="Proxy Base URL"
name="proxy_base_url"
normalize={(value) => 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();
},
},
]}
>
<TextInput placeholder="https://example.com" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.sso_provider !== currentValues.sso_provider}
>
{({ getFieldValue }) => {
const provider = getFieldValue("sso_provider");
return provider === "okta" || provider === "generic" ? (
<Form.Item label="Use Role Mappings" name="use_role_mappings" valuePropName="checked">
<Checkbox />
</Form.Item>
) : null;
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.use_role_mappings !== currentValues.use_role_mappings
}
>
{({ getFieldValue }) => {
const useRoleMappings = getFieldValue("use_role_mappings");
return useRoleMappings ? (
<Form.Item
label="Group Claim"
name="group_claim"
rules={[{ required: true, message: "Please enter the group claim" }]}
>
<TextInput />
</Form.Item>
) : null;
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.use_role_mappings !== currentValues.use_role_mappings
}
>
{({ getFieldValue }) => {
const useRoleMappings = getFieldValue("use_role_mappings");
return useRoleMappings ? (
<>
<Form.Item label="Default Role" name="default_role" initialValue="Internal User">
<Select>
<Select.Option value="internal_user_viewer">Internal Viewer</Select.Option>
<Select.Option value="internal_user">Internal User</Select.Option>
<Select.Option value="proxy_admin_viewer">Admin Viewer</Select.Option>
<Select.Option value="proxy_admin">Proxy Admin</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Proxy Admin Teams" name="proxy_admin_teams">
<TextInput />
</Form.Item>
<Form.Item label="Admin Viewer Teams" name="admin_viewer_teams">
<TextInput />
</Form.Item>
<Form.Item label="Internal User Teams" name="internal_user_teams">
<TextInput />
</Form.Item>
<Form.Item label="Internal Viewer Teams" name="internal_viewer_teams">
<TextInput />
</Form.Item>
</>
) : null;
}}
</Form.Item>
</>
<div
style={{
textAlign: "right",
marginTop: "10px",
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
gap: "8px",
}}
>
{ssoConfigured && (
<Button2
onClick={() => setIsClearConfirmModalVisible(true)}
style={{
backgroundColor: "#6366f1",
borderColor: "#6366f1",
color: "white",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#5558eb";
e.currentTarget.style.borderColor = "#5558eb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#6366f1";
e.currentTarget.style.borderColor = "#6366f1";
}}
>
Clear
</Button2>
)}
<Button2 htmlType="submit">Save</Button2>
</div>
</Form>
</Modal>
{/* Clear Confirmation Modal */}
<Modal
title="Confirm Clear SSO Settings"
open={isClearConfirmModalVisible}
onOk={handleClearSSO}
onCancel={() => setIsClearConfirmModalVisible(false)}
okText="Yes, Clear"
cancelText="Cancel"
okButtonProps={{
danger: true,
style: {
backgroundColor: "#dc2626",
borderColor: "#dc2626",
},
onOpenChange={(o) => {
if (!o) handleAddSSOCancel();
}}
>
<p>Are you sure you want to clear all SSO settings? This action cannot be undone.</p>
<p>Users will no longer be able to login using SSO after this change.</p>
</Modal>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle>{ssoConfigured ? "Edit SSO Settings" : "Add SSO"}</DialogTitle>
</DialogHeader>
<FormProvider {...rhfForm}>
<form onSubmit={onSubmit} className="space-y-4">
<Controller
control={rhfForm.control}
name="sso_provider"
rules={{ required: "Please select an SSO provider" }}
render={({ field, fieldState }) => (
<div className="grid grid-cols-[1fr_2fr] gap-4 items-start">
<Label htmlFor="sso-provider" className="mt-2 text-left">
SSO Provider <span className="text-destructive">*</span>
</Label>
<div className="space-y-1">
<Select value={(field.value as string) || ""} onValueChange={field.onChange}>
<SelectTrigger id="sso-provider" aria-label="SSO Provider">
<SelectValue placeholder="Select an SSO provider" />
</SelectTrigger>
<SelectContent>
{Object.entries(ssoProviderLogoMap).map(([value, logo]) => {
const label =
value.toLowerCase() === "okta"
? "Okta / Auth0"
: value.charAt(0).toUpperCase() + value.slice(1);
return (
<SelectItem key={value} value={value}>
<div className="flex items-center gap-3">
{logo && (
<img
src={logo}
alt={value}
style={{ height: 24, width: 24, objectFit: "contain" }}
/>
)}
<span>{label} SSO</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</div>
</div>
)}
/>
<Modal
title="SSO Setup Instructions"
open={isInstructionsModalVisible}
width={800}
footer={null}
onOk={handleInstructionsOk}
onCancel={handleInstructionsCancel}
{provider ? <ProviderFields provider={provider} /> : null}
<Controller
control={rhfForm.control}
name="user_email"
rules={{ required: "Please enter the email of the proxy admin" }}
render={({ field, fieldState }) => (
<div className="grid grid-cols-[1fr_2fr] gap-4 items-start">
<Label htmlFor="sso-user-email" className="mt-2 text-left">
Proxy Admin Email <span className="text-destructive">*</span>
</Label>
<div className="space-y-1">
<Input
id="sso-user-email"
value={(field.value as string) ?? ""}
onChange={field.onChange}
/>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</div>
</div>
)}
/>
<Controller
control={rhfForm.control}
name="proxy_base_url"
rules={{
required: "Please enter the proxy base url",
validate: (rawValue) => {
const value = (rawValue ?? "").trim();
if (!URL_PATTERN.test(value)) {
return "URL must start with http:// or https://";
}
if (value.endsWith("/")) {
return "URL must not end with a trailing slash";
}
return true;
},
}}
render={({ field, fieldState }) => (
<div className="grid grid-cols-[1fr_2fr] gap-4 items-start">
<Label htmlFor="sso-proxy-base-url" className="mt-2 text-left">
Proxy Base URL <span className="text-destructive">*</span>
</Label>
<div className="space-y-1">
<Input
id="sso-proxy-base-url"
placeholder="https://example.com"
value={(field.value as string) ?? ""}
onChange={(e) => field.onChange(e.target.value)}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</div>
</div>
)}
/>
{(provider === "okta" || provider === "generic") && (
<Controller
control={rhfForm.control}
name="use_role_mappings"
render={({ field }) => (
<div className="grid grid-cols-[1fr_2fr] gap-4 items-center">
<Label htmlFor="sso-use-role-mappings" className="text-left">
Use Role Mappings
</Label>
<Checkbox
id="sso-use-role-mappings"
checked={!!field.value}
onCheckedChange={(c) => field.onChange(c === true)}
aria-label="Use Role Mappings"
/>
</div>
)}
/>
)}
{useRoleMappings ? <RoleMappingFields /> : null}
<div className="flex justify-end items-center gap-2 pt-3">
{ssoConfigured && (
<Button
type="button"
variant="secondary"
onClick={() => setIsClearConfirmModalVisible(true)}
>
Clear
</Button>
)}
<Button type="submit">Save</Button>
</div>
</form>
</FormProvider>
</DialogContent>
</Dialog>
<Dialog
open={isClearConfirmModalVisible}
onOpenChange={(o) => {
if (!o) setIsClearConfirmModalVisible(false);
}}
>
<p>Follow these steps to complete the SSO setup:</p>
<Text className="mt-2">1. DO NOT Exit this TAB</Text>
<Text className="mt-2">2. Open a new tab, visit your proxy base url</Text>
<Text className="mt-2">3. Confirm your SSO is configured correctly and you can login on the new Tab</Text>
<Text className="mt-2">4. If Step 3 is successful, you can close this tab</Text>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 onClick={handleInstructionsOk}>Done</Button2>
</div>
</Modal>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Clear SSO Settings</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<p>Are you sure you want to clear all SSO settings? This action cannot be undone.</p>
<p>Users will no longer be able to login using SSO after this change.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsClearConfirmModalVisible(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleClearSSO}>
Yes, Clear
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isInstructionsModalVisible}
onOpenChange={(o) => {
if (!o) handleInstructionsCancel();
}}
>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle>SSO Setup Instructions</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<p>Follow these steps to complete the SSO setup:</p>
<p className="mt-2">1. DO NOT Exit this TAB</p>
<p className="mt-2">2. Open a new tab, visit your proxy base url</p>
<p className="mt-2">3. Confirm your SSO is configured correctly and you can login on the new Tab</p>
<p className="mt-2">4. If Step 3 is successful, you can close this tab</p>
</div>
<DialogFooter>
<Button onClick={handleInstructionsOk}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export { ssoProviderConfigs }; // Export for use in other components
export { ssoProviderConfigs };
export default SSOModals;

View file

@ -1,7 +1,23 @@
import React, { useState, useEffect } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Text, Button, Callout, TextInput } from "@tremor/react";
import { Modal, Form, Spin, Select } from "antd";
import { FileText, CheckCircle2, Plus } from "lucide-react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { getGlobalLitellmHeaderName } from "@/components/networking";
import NotificationsManager from "./molecules/notifications_manager";
@ -25,18 +41,20 @@ interface CloudZeroSettingsView {
type ExportType = "cloudzero" | "csv";
const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onClose, accessToken }) => {
const [form] = Form.useForm();
const form = useForm<CloudZeroSettings>({
defaultValues: { api_key: "", connection_id: "" },
});
const [loading, setLoading] = useState(false);
const [existingSettings, setExistingSettings] = useState<CloudZeroSettingsView | null>(null);
const [settingsLoading, setSettingsLoading] = useState(false);
const [exportType, setExportType] = useState<ExportType>("cloudzero");
const [exportLoading, setExportLoading] = useState(false);
// Load existing settings when modal opens
useEffect(() => {
if (isOpen && accessToken) {
loadExistingSettings();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, accessToken]);
const loadExistingSettings = async () => {
@ -53,12 +71,11 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
if (response.ok) {
const settings = await response.json();
setExistingSettings(settings);
// Pre-populate form with existing settings (except masked API key)
form.setFieldsValue({
form.reset({
api_key: "",
connection_id: settings.connection_id,
});
} else if (response.status !== 404) {
// 404 means no settings configured yet, which is fine
const errorData = await response.json();
NotificationsManager.fromBackend(`Failed to load existing settings: ${errorData.error || "Unknown error"}`);
}
@ -81,7 +98,6 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
const endpoint = existingSettings ? "/cloudzero/settings" : "/cloudzero/init";
const method = existingSettings ? "PUT" : "POST";
// Add default timezone for backend compatibility
const payload = {
...values,
timezone: "UTC",
@ -158,7 +174,6 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
const handleExportCSV = async () => {
setExportLoading(true);
try {
// TODO: Implement CSV export functionality
NotificationsManager.info("CSV export functionality coming soon!");
onClose();
} catch (error) {
@ -171,9 +186,10 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
const handleExport = async () => {
if (exportType === "cloudzero") {
// Check if settings exist, if not save them first
if (!existingSettings) {
const values = await form.validateFields();
const ok = await form.trigger();
if (!ok) return;
const values = form.getValues();
const success = await handleSaveCloudZeroSettings(values);
if (!success) return;
}
@ -183,141 +199,157 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
}
};
const handleModalClose = () => {
form.resetFields();
const handleModalClose = (open: boolean) => {
if (open) return;
form.reset({ api_key: "", connection_id: "" });
setExportType("cloudzero");
setExistingSettings(null);
onClose();
};
const exportOptions = [
{
value: "cloudzero",
label: (
<div className="flex items-center gap-2">
<img
src="/cloudzero.png"
alt="CloudZero"
className="w-5 h-5"
onError={(e) => {
// Fallback to text if image fails to load
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<span>Export to CloudZero</span>
</div>
),
},
{
value: "csv",
label: (
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span>Export to CSV</span>
</div>
),
},
];
return (
<Modal title="Export Data" open={isOpen} onCancel={handleModalClose} footer={null} width={600} destroyOnHidden>
<div className="space-y-4">
{/* Export Type Selection */}
<div>
<Text className="font-medium mb-2 block">Export Destination</Text>
<Select value={exportType} onChange={setExportType} options={exportOptions} className="w-full" size="large" />
</div>
{/* CloudZero Configuration */}
{exportType === "cloudzero" && (
<div>
{settingsLoading ? (
<div className="flex justify-center py-8">
<Spin size="large" />
</div>
) : (
<>
{existingSettings && (
<Callout
title="Existing CloudZero Configuration"
icon={() => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
color="green"
className="mb-4"
>
<Text>
API Key: {existingSettings.api_key_masked}
<br />
Connection ID: {existingSettings.connection_id}
</Text>
</Callout>
)}
{!existingSettings && (
<Form form={form} layout="vertical">
<Form.Item
label="CloudZero API Key"
name="api_key"
rules={[{ required: true, message: "Please enter your CloudZero API key" }]}
>
<TextInput type="password" placeholder="Enter your CloudZero API key" />
</Form.Item>
<Form.Item
label="Connection ID"
name="connection_id"
rules={[{ required: true, message: "Please enter the CloudZero connection ID" }]}
>
<TextInput placeholder="Enter CloudZero connection ID" />
</Form.Item>
</Form>
)}
</>
)}
<Dialog open={isOpen} onOpenChange={handleModalClose}>
<DialogContent className="max-w-[600px]">
<DialogHeader>
<DialogTitle>Export Data</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Export Type Selection */}
<div className="space-y-2">
<Label className="font-medium">Export Destination</Label>
<Select value={exportType} onValueChange={(v) => setExportType(v as ExportType)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cloudzero">
<div className="flex items-center gap-2">
<img
src="/cloudzero.png"
alt="CloudZero"
className="w-5 h-5"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<span>Export to CloudZero</span>
</div>
</SelectItem>
<SelectItem value="csv">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5" />
<span>Export to CSV</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* CSV Export Info */}
{exportType === "csv" && (
<Callout
title="CSV Export"
icon={() => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
)}
color="blue"
>
<Text>Export your usage data as a CSV file for analysis in spreadsheet applications.</Text>
</Callout>
)}
{/* CloudZero Configuration */}
{exportType === "cloudzero" && (
<div>
{settingsLoading ? (
<div className="flex justify-center py-8">
<Skeleton className="h-10 w-full" />
</div>
) : (
<>
{existingSettings && (
<div className="mb-4 rounded-md border border-border bg-muted p-4 flex gap-3">
<CheckCircle2 className="h-5 w-5 mt-0.5 text-primary shrink-0" />
<div className="space-y-1">
<p className="font-medium">Existing CloudZero Configuration</p>
<p className="text-sm text-muted-foreground">
API Key: {existingSettings.api_key_masked}
<br />
Connection ID: {existingSettings.connection_id}
</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-2 pt-4">
<Button variant="secondary" onClick={handleModalClose}>
Cancel
</Button>
<Button onClick={handleExport} loading={loading || exportLoading} disabled={loading || exportLoading}>
{exportType === "cloudzero" ? "Export to CloudZero" : "Export CSV"}
</Button>
{!existingSettings && (
<FormProvider {...form}>
<form className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cz-api-key">
CloudZero API Key <span className="text-destructive">*</span>
</Label>
<Controller
control={form.control}
name="api_key"
rules={{ required: "Please enter your CloudZero API key" }}
render={({ field, fieldState }) => (
<>
<Input
id="cz-api-key"
type="password"
placeholder="Enter your CloudZero API key"
{...field}
/>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cz-conn-id">
Connection ID <span className="text-destructive">*</span>
</Label>
<Controller
control={form.control}
name="connection_id"
rules={{ required: "Please enter the CloudZero connection ID" }}
render={({ field, fieldState }) => (
<>
<Input
id="cz-conn-id"
placeholder="Enter CloudZero connection ID"
{...field}
/>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</>
)}
/>
</div>
</form>
</FormProvider>
)}
</>
)}
</div>
)}
{/* CSV Export Info */}
{exportType === "csv" && (
<div className="rounded-md border border-border bg-muted p-4 flex gap-3">
<Plus className="h-5 w-5 mt-0.5 text-primary shrink-0" />
<div>
<p className="font-medium">CSV Export</p>
<p className="text-sm text-muted-foreground">
Export your usage data as a CSV file for analysis in spreadsheet applications.
</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-2 pt-4">
<Button variant="outline" onClick={() => handleModalClose(false)}>
Cancel
</Button>
<Button onClick={handleExport} disabled={loading || exportLoading}>
{exportType === "cloudzero" ? "Export to CloudZero" : "Export CSV"}
</Button>
</div>
</div>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};

View file

@ -1,4 +1,6 @@
import React, { useEffect, useState, useCallback } from "react";
import React, { useEffect, useState, useCallback, useMemo } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -8,7 +10,15 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Form, Select as AntdSelect } from "antd";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
import {
fetchAvailableModels,
@ -28,6 +38,96 @@ interface EditAutoRouterModalProps {
userRole: string;
}
interface FormValues {
auto_router_name: string;
auto_router_default_model: string;
auto_router_embedding_model: string;
model_access_group: string[];
}
function TagsInput({
value,
onChange,
options,
placeholder,
}: {
value: string[];
onChange: (next: string[]) => void;
options: string[];
placeholder?: string;
}) {
const [query, setQuery] = useState("");
const selected = value ?? [];
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o)),
[options, selected],
);
const addTag = (tag: string) => {
const trimmed = tag.trim();
if (!trimmed) return;
if (selected.includes(trimmed)) return;
onChange([...selected, trimmed]);
setQuery("");
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(query);
} else if (e.key === "Backspace" && !query && selected.length) {
onChange(selected.slice(0, -1));
}
}}
/>
</div>
{remaining.length > 0 && (
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder="Pick from existing groups" />
</SelectTrigger>
<SelectContent>
{remaining.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => (
<Badge key={v} variant="secondary" className="flex items-center gap-1">
{v}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${v}`}
>
<X size={12} />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
isVisible,
onCancel,
@ -36,14 +136,17 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
accessToken,
userRole,
}) => {
const [form] = Form.useForm();
const form = useForm<FormValues>({
defaultValues: {
auto_router_name: "",
auto_router_default_model: "",
auto_router_embedding_model: "",
model_access_group: [],
},
});
const [loading, setLoading] = useState(false);
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [_showCustomDefaultModel, setShowCustomDefaultModel] =
useState<boolean>(false);
const [_showCustomEmbeddingModel, setShowCustomEmbeddingModel] =
useState<boolean>(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [routerConfig, setRouterConfig] = useState<any>(null);
@ -62,7 +165,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
setRouterConfig(parsedConfig);
form.setFieldsValue({
form.reset({
auto_router_name: modelData.model_name,
auto_router_default_model:
modelData.litellm_params?.auto_router_default_model || "",
@ -70,23 +173,11 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
modelData.litellm_params?.auto_router_embedding_model || "",
model_access_group: modelData.model_info?.access_groups || [],
});
const allModelGroups = new Set(
modelInfo.map((model) => model.model_group),
);
setShowCustomDefaultModel(
!allModelGroups.has(modelData.litellm_params?.auto_router_default_model),
);
setShowCustomEmbeddingModel(
!allModelGroups.has(
modelData.litellm_params?.auto_router_embedding_model,
),
);
} catch (error) {
console.error("Error parsing auto router config:", error);
NotificationsManager.fromBackend("Error loading auto router configuration");
}
}, [form, modelData, modelInfo]);
}, [form, modelData]);
useEffect(() => {
if (isVisible && modelData) {
@ -132,12 +223,10 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
}
}, [isVisible, accessToken]);
const handleSubmit = async () => {
const handleSubmit = form.handleSubmit(async (values) => {
try {
setLoading(true);
const values = await form.validateFields();
// Prepare the updated litellm_params
const updatedLitellmParams = {
...modelData.litellm_params,
auto_router_config: JSON.stringify(routerConfig),
@ -145,7 +234,6 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
auto_router_embedding_model: values.auto_router_embedding_model || undefined,
};
// Prepare updated model_info
const updatedModelInfo = {
...modelData.model_info,
access_groups: values.model_access_group || [],
@ -175,12 +263,9 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
} finally {
setLoading(false);
}
};
});
const modelOptions = modelInfo.map((model) => ({
value: model.model_group,
label: model.model_group,
}));
const modelOptions = modelInfo.map((model) => model.model_group);
return (
<Dialog
@ -197,87 +282,113 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
models, and access settings.
</p>
<Form form={form} layout="vertical" className="space-y-4">
<Form.Item
label="Auto Router Name"
name="auto_router_name"
rules={[
{ required: true, message: "Auto router name is required" },
]}
>
<Input placeholder="e.g., auto_router_1, smart_routing" />
</Form.Item>
<div className="w-full">
<RouterConfigBuilder
modelInfo={modelInfo}
value={routerConfig}
onChange={(config) => {
setRouterConfig(config);
}}
/>
</div>
<Form.Item
label="Default Model"
name="auto_router_default_model"
rules={[
{ required: true, message: "Default model is required" },
]}
>
<AntdSelect
placeholder="Select a default model"
onChange={(value) => {
setShowCustomDefaultModel(value === "custom");
}}
options={[
...modelOptions,
{ value: "custom", label: "Enter custom model name" },
]}
showSearch={true}
/>
</Form.Item>
<Form.Item
label="Embedding Model"
name="auto_router_embedding_model"
>
<AntdSelect
placeholder="Select an embedding model (optional)"
onChange={(value) => {
setShowCustomEmbeddingModel(value === "custom");
}}
options={[
...modelOptions,
{ value: "custom", label: "Enter custom model name" },
]}
showSearch={true}
allowClear
/>
</Form.Item>
{userRole === "Admin" && (
<Form.Item
label="Model Access Groups"
name="model_access_group"
tooltip="Control who can access this auto router"
>
<AntdSelect
mode="tags"
showSearch
placeholder="Select existing groups or type to create new ones"
optionFilterProp="children"
tokenSeparators={[","]}
options={modelAccessGroups.map((group) => ({
value: group,
label: group,
}))}
maxTagCount="responsive"
allowClear
<FormProvider {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="auto-router-name">
Auto Router Name <span className="text-destructive">*</span>
</Label>
<Input
id="auto-router-name"
placeholder="e.g., auto_router_1, smart_routing"
{...form.register("auto_router_name", {
required: "Auto router name is required",
})}
/>
</Form.Item>
)}
</Form>
{form.formState.errors.auto_router_name && (
<p className="text-sm text-destructive">
{form.formState.errors.auto_router_name.message as string}
</p>
)}
</div>
<div className="w-full">
<RouterConfigBuilder
modelInfo={modelInfo}
value={routerConfig}
onChange={(config) => {
setRouterConfig(config);
}}
/>
</div>
<div className="space-y-2">
<Label>
Default Model <span className="text-destructive">*</span>
</Label>
<Controller
control={form.control}
name="auto_router_default_model"
rules={{ required: "Default model is required" }}
render={({ field, fieldState }) => (
<>
<Select value={field.value || ""} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a default model" />
</SelectTrigger>
<SelectContent>
{modelOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</>
)}
/>
</div>
<div className="space-y-2">
<Label>Embedding Model</Label>
<Controller
control={form.control}
name="auto_router_embedding_model"
render={({ field }) => (
<Select
value={field.value || ""}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select an embedding model (optional)" />
</SelectTrigger>
<SelectContent>
{modelOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
{userRole === "Admin" && (
<div className="space-y-2">
<Label>Model Access Groups</Label>
<p className="text-xs text-muted-foreground">
Control who can access this auto router
</p>
<Controller
control={form.control}
name="model_access_group"
render={({ field }) => (
<TagsInput
value={field.value ?? []}
onChange={field.onChange}
options={modelAccessGroups}
placeholder="Select existing groups or type to create new ones"
/>
)}
/>
</div>
)}
</form>
</FormProvider>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>

View file

@ -8,10 +8,23 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Check, Copy, AlertTriangle, RefreshCw } from "lucide-react";
import { Form, InputNumber, Input as AntInput } from "antd";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Check,
Copy,
AlertTriangle,
RefreshCw,
HelpCircle,
} from "lucide-react";
import { add } from "date-fns";
import { useEffect, useState } from "react";
import { Controller, FormProvider, useForm, useWatch } from "react-hook-form";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { KeyResponse } from "../key_team_helpers/key_list";
import NotificationManager from "../molecules/notifications_manager";
@ -24,6 +37,15 @@ interface RegenerateKeyModalProps {
onKeyUpdate?: (updatedKeyData: Partial<KeyResponse>) => void;
}
interface RegenerateFormValues {
key_alias: string;
max_budget: number | null;
tpm_limit: number | null;
rpm_limit: number | null;
duration: string;
grace_period: string;
}
export function RegenerateKeyModal({
selectedToken,
visible,
@ -31,21 +53,33 @@ export function RegenerateKeyModal({
onKeyUpdate,
}: RegenerateKeyModalProps) {
const { accessToken } = useAuthorized();
const [form] = Form.useForm();
const form = useForm<RegenerateFormValues>({
defaultValues: {
key_alias: "",
max_budget: null,
tpm_limit: null,
rpm_limit: null,
duration: "",
grace_period: "",
},
});
const [regeneratedKey, setRegeneratedKey] = useState<string | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [regenerateFormData, setRegenerateFormData] = useState<any>(null);
const [newExpiryTime, setNewExpiryTime] = useState<string | null>(null);
const [isRegenerating, setIsRegenerating] = useState(false);
const [copied, setCopied] = useState(false);
const duration = useWatch({ control: form.control, name: "duration" });
useEffect(() => {
if (visible && selectedToken && accessToken) {
form.setFieldsValue({
key_alias: selectedToken.key_alias,
max_budget: selectedToken.max_budget,
tpm_limit: selectedToken.tpm_limit,
rpm_limit: selectedToken.rpm_limit,
form.reset({
key_alias: selectedToken.key_alias ?? "",
max_budget:
(selectedToken.max_budget as number | null | undefined) ?? null,
tpm_limit:
(selectedToken.tpm_limit as number | null | undefined) ?? null,
rpm_limit:
(selectedToken.rpm_limit as number | null | undefined) ?? null,
duration: selectedToken.duration || "",
grace_period: "",
});
@ -63,7 +97,6 @@ export function RegenerateKeyModal({
throw new Error("Invalid duration format");
}
const now = new Date();
// Check "mo" before "m" to avoid a false prefix match (e.g. "1mo" → minutes).
let newExpiry: Date;
if (duration.endsWith("mo")) {
newExpiry = add(now, { months: amount });
@ -89,20 +122,18 @@ export function RegenerateKeyModal({
};
useEffect(() => {
if (regenerateFormData?.duration) {
setNewExpiryTime(calculateNewExpiryTime(regenerateFormData.duration));
if (duration) {
setNewExpiryTime(calculateNewExpiryTime(duration));
} else {
setNewExpiryTime(null);
}
}, [regenerateFormData?.duration]);
}, [duration]);
const handleRegenerateKey = async () => {
const handleRegenerateKey = form.handleSubmit(async (formValues) => {
if (!selectedToken || !accessToken) return;
setIsRegenerating(true);
try {
const formValues = await form.validateFields();
const response = await regenerateKeyCall(
accessToken,
selectedToken.token || selectedToken.token_id,
@ -115,9 +146,9 @@ export function RegenerateKeyModal({
...response,
token: response.token || response.key_id || selectedToken.token,
key_name: response.key,
max_budget: formValues.max_budget,
tpm_limit: formValues.tpm_limit,
rpm_limit: formValues.rpm_limit,
max_budget: formValues.max_budget as number | undefined,
tpm_limit: formValues.tpm_limit as number | undefined,
rpm_limit: formValues.rpm_limit as number | undefined,
expires: formValues.duration
? (calculateNewExpiryTime(formValues.duration) ??
selectedToken.expires)
@ -134,13 +165,20 @@ export function RegenerateKeyModal({
NotificationManager.fromBackend(error);
setIsRegenerating(false);
}
};
});
const handleClose = () => {
setRegeneratedKey(null);
setIsRegenerating(false);
setCopied(false);
form.resetFields();
form.reset({
key_alias: "",
max_budget: null,
tpm_limit: null,
rpm_limit: null,
duration: "",
grace_period: "",
});
onClose();
};
@ -183,46 +221,79 @@ export function RegenerateKeyModal({
</div>
</div>
) : (
<Form
form={form}
layout="vertical"
className="mt-1"
onValuesChange={(changedValues) => {
if ("duration" in changedValues) {
setRegenerateFormData(
(prev: { duration?: string }) => ({
...prev,
duration: changedValues.duration,
}),
);
}
}}
>
<Form.Item name="key_alias" label="Key Alias">
<Input disabled />
</Form.Item>
<FormProvider {...form}>
<form onSubmit={handleRegenerateKey} className="mt-1 space-y-4">
<div className="space-y-2">
<Label htmlFor="key_alias">Key Alias</Label>
<Input id="key_alias" disabled {...form.register("key_alias")} />
</div>
<div className="grid grid-cols-3 gap-3">
<Form.Item name="max_budget" label="Max Budget (USD)">
<InputNumber
step={0.01}
precision={2}
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item name="tpm_limit" label="TPM Limit">
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="rpm_limit" label="RPM Limit">
<InputNumber style={{ width: "100%" }} />
</Form.Item>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label htmlFor="max_budget">Max Budget (USD)</Label>
<Controller
control={form.control}
name="max_budget"
render={({ field }) => (
<Input
id="max_budget"
type="number"
step={0.01}
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value;
field.onChange(v === "" ? null : Number(v));
}}
/>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tpm_limit">TPM Limit</Label>
<Controller
control={form.control}
name="tpm_limit"
render={({ field }) => (
<Input
id="tpm_limit"
type="number"
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value;
field.onChange(v === "" ? null : Number(v));
}}
/>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rpm_limit">RPM Limit</Label>
<Controller
control={form.control}
name="rpm_limit"
render={({ field }) => (
<Input
id="rpm_limit"
type="number"
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value;
field.onChange(v === "" ? null : Number(v));
}}
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Form.Item
name="duration"
label="Expire Key"
extra={
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="duration">Expire Key</Label>
<Input
id="duration"
placeholder="e.g. 30s, 30h, 30d"
{...form.register("duration")}
/>
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground text-xs">
Current expiry:{" "}
@ -237,31 +308,48 @@ export function RegenerateKeyModal({
</span>
)}
</div>
}
>
<AntInput placeholder="e.g. 30s, 30h, 30d" />
</Form.Item>
<Form.Item
name="grace_period"
label="Grace Period"
tooltip="Keep the old key valid for this duration after rotation. Both keys work during this period for seamless cutover. Empty = immediate revoke."
extra={
<span className="text-muted-foreground text-xs">
Recommended: 24h to 72h for production keys
</span>
}
rules={[
{
pattern: /^(\d+(s|m|h|d|w|mo))?$/,
message:
"Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo",
},
]}
>
<AntInput placeholder="e.g. 24h, 2d" />
</Form.Item>
</div>
</Form>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label htmlFor="grace_period">Grace Period</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
Keep the old key valid for this duration after rotation.
Both keys work during this period for seamless cutover.
Empty = immediate revoke.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Input
id="grace_period"
placeholder="e.g. 24h, 2d"
aria-invalid={!!form.formState.errors.grace_period}
{...form.register("grace_period", {
pattern: {
value: /^(\d+(s|m|h|d|w|mo))?$/,
message:
"Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo",
},
})}
/>
{form.formState.errors.grace_period ? (
<p className="text-sm text-destructive">
{form.formState.errors.grace_period.message as string}
</p>
) : (
<span className="text-muted-foreground text-xs">
Recommended: 24h to 72h for production keys
</span>
)}
</div>
</div>
</form>
</FormProvider>
)}
<DialogFooter>

View file

@ -1,6 +1,11 @@
import React, { useState } from "react";
import { X, MessageSquare, ArrowRight, ArrowLeft } from "lucide-react";
import { Button, Input, Radio, Space, Progress, Checkbox } from "antd";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
interface SurveyModalProps {
isOpen: boolean;
@ -57,17 +62,13 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Steps: 1=company?, 2=company name (conditional), 3=when, 4=why, 5=email
// If not at company: skip step 2, so total is 4
// If at company: total is 5
const totalSteps = data.usingAtCompany === true ? 5 : 4;
if (!isOpen) return null;
const handleNext = () => {
// Skip company name step if not using at company
if (step === 1 && data.usingAtCompany === false) {
setStep(3); // Skip to "when did you start"
setStep(3);
} else if (step < 5) {
setStep(step + 1);
} else {
@ -77,7 +78,7 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
const handleBack = () => {
if (step === 3 && data.usingAtCompany === false) {
setStep(1); // Go back to first question if we skipped company name
setStep(1);
} else {
setStep(step - 1);
}
@ -86,7 +87,6 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
const handleSubmit = async () => {
setIsSubmitting(true);
try {
// Map reason IDs to readable labels
const reasonLabels: Record<string, string> = {
oss_adoption: "OSS Adoption (stars, contributors, forks)",
ai_integration: "AI Integration (Langfuse, OTEL, S3, Azure Content Safety)",
@ -101,9 +101,8 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
return reasonLabels[r] || r;
});
// Submit to feedback endpoint (redirects to Google Form)
const feedbackUrl = "https://feedback.litellm.ai/survey";
const formData = new URLSearchParams({
"entry.2015264290": data.usingAtCompany ? "Yes" : "No",
"entry.1876243786": data.companyName || "",
@ -118,7 +117,6 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
body: formData,
});
} catch (error) {
// Silently fail - don't block the user experience
console.error("Failed to submit survey:", error);
}
setIsSubmitting(false);
@ -146,19 +144,17 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
if (step === 2) return data.companyName.trim().length > 0;
if (step === 3) return data.startDate !== "";
if (step === 4) {
// If "other" is selected, require the text field
if (data.reasons.includes("other")) {
return data.reasons.length > 0 && data.otherReason.trim().length > 0;
}
return data.reasons.length > 0;
}
if (step === 5) return true; // Email is optional
if (step === 5) return true;
return false;
};
const getStepNumber = () => {
if (data.usingAtCompany === false) {
// When not at company: skip step 2, so steps 3,4,5 become 2,3,4
if (step === 1) return 1;
if (step === 3) return 2;
if (step === 4) return 3;
@ -168,7 +164,6 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
};
const renderStepContent = () => {
// Step 1: Using at company?
if (step === 1) {
return (
<div className="space-y-6">
@ -202,14 +197,13 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
);
}
// Step 2: Company name (only if using at company)
if (step === 2 && data.usingAtCompany === true) {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">What company are you using LiteLLM at?</h2>
<p className="text-gray-500">This helps us understand our user base better.</p>
<Input
size="large"
className="h-11 text-base"
placeholder="Enter your company name"
value={data.companyName}
onChange={(e) => updateData("companyName", e.target.value)}
@ -219,36 +213,34 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
);
}
// Step 3: When did you start?
if (step === 3) {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">When did you start using LiteLLM?</h2>
<Radio.Group
<RadioGroup
value={data.startDate}
onChange={(e) => updateData("startDate", e.target.value)}
className="w-full"
onValueChange={(v) => updateData("startDate", v)}
className="w-full flex flex-col gap-2"
>
<Space direction="vertical" className="w-full">
{["Less than a month ago", "1-3 months ago", "3-6 months ago", "More than 6 months ago"].map((option) => (
<label
key={option}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all w-full ${
data.startDate === option
? "border-blue-600 bg-blue-50 ring-1 ring-blue-600"
: "border-gray-200 hover:bg-gray-50"
}`}
>
<Radio value={option}>{option}</Radio>
</label>
))}
</Space>
</Radio.Group>
{["Less than a month ago", "1-3 months ago", "3-6 months ago", "More than 6 months ago"].map((option) => (
<Label
key={option}
htmlFor={`start-${option}`}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all w-full ${
data.startDate === option
? "border-blue-600 bg-blue-50 ring-1 ring-blue-600"
: "border-gray-200 hover:bg-gray-50"
}`}
>
<RadioGroupItem value={option} id={`start-${option}`} className="mr-2" />
<span>{option}</span>
</Label>
))}
</RadioGroup>
</div>
);
}
// Step 4: Why did you pick LiteLLM?
if (step === 4) {
return (
<div className="space-y-6">
@ -259,17 +251,10 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
const isSelected = data.reasons.includes(option.id);
return (
<div key={option.id}>
<div
role="button"
tabIndex={0}
<button
type="button"
onClick={() => toggleReason(option.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleReason(option.id);
}
}}
className={`flex items-start p-4 rounded-lg border cursor-pointer transition-all ${
className={`w-full flex items-start p-4 rounded-lg border cursor-pointer transition-all text-left ${
isSelected
? "border-blue-600 bg-blue-50 ring-1 ring-blue-600"
: "border-gray-200 hover:bg-gray-50"
@ -280,8 +265,7 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
<span className="block font-medium text-gray-900">{option.label}</span>
<span className="text-sm text-gray-500">{option.description}</span>
</div>
</div>
{/* Show text input if "Other" is selected */}
</button>
{option.id === "other" && isSelected && (
<Input
className="mt-2 ml-7"
@ -300,7 +284,6 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
);
}
// Step 5: Email (optional)
if (step === 5) {
return (
<div className="space-y-6">
@ -309,7 +292,7 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
Leave your email and we may reach out to learn more about your experience. This is completely optional.
</p>
<Input
size="large"
className="h-11 text-base"
type="email"
placeholder="your@email.com (optional)"
value={data.email}
@ -350,7 +333,7 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
</div>
{/* Progress Bar */}
<Progress percent={(getStepNumber() / totalSteps) * 100} showInfo={false} strokeColor="#2563eb" className="m-0" />
<Progress value={(getStepNumber() / totalSteps) * 100} className="h-1 rounded-none" />
{/* Content */}
<div className="p-8 flex-1 overflow-y-auto">{renderStepContent()}</div>
@ -362,15 +345,14 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
</div>
<div className="flex gap-3">
{step > 1 && (
<Button onClick={handleBack} disabled={isSubmitting} icon={<ArrowLeft className="h-4 w-4" />}>
<Button variant="outline" onClick={handleBack} disabled={isSubmitting}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
<Button
type="primary"
onClick={handleNext}
disabled={!isStepValid() || isSubmitting}
loading={isSubmitting}
className="min-w-[100px]"
>
{isLastStep ? "Submit" : "Next"}
@ -382,4 +364,3 @@ export function SurveyModal({ isOpen, onClose, onComplete }: SurveyModalProps) {
</div>
);
}