mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(ui): migrate standalone modals to shadcn
Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
75e172b0e0
commit
9028e69e76
8 changed files with 1309 additions and 971 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue