mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(ui): migrate SSO settings modals to shadcn
Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
7e0f12d847
commit
5b15606577
6 changed files with 799 additions and 626 deletions
|
|
@ -1,5 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,10 +13,9 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Form } from "antd";
|
||||
import React from "react";
|
||||
import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
|
||||
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
|
||||
|
||||
import BaseSSOSettingsForm, { SSOSettingsFormValues } from "./BaseSSOSettingsForm";
|
||||
import { processSSOSettingsPayload } from "../utils";
|
||||
|
||||
interface AddSSOSettingsModalProps {
|
||||
|
|
@ -22,16 +24,24 @@ interface AddSSOSettingsModalProps {
|
|||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const emptyValues: SSOSettingsFormValues = {
|
||||
sso_provider: undefined,
|
||||
user_email: "",
|
||||
proxy_base_url: "",
|
||||
};
|
||||
|
||||
const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({
|
||||
isVisible,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useForm<SSOSettingsFormValues>({
|
||||
defaultValues: emptyValues,
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const { mutateAsync, isPending } = useEditSSOSettings();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleFormSubmit = async (formValues: Record<string, any>) => {
|
||||
const onSubmit = form.handleSubmit(async (formValues) => {
|
||||
const payload = processSSOSettingsPayload(formValues);
|
||||
|
||||
await mutateAsync(payload, {
|
||||
|
|
@ -45,10 +55,10 @@ const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({
|
|||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(emptyValues);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
|
@ -61,15 +71,24 @@ const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Add SSO</DialogTitle>
|
||||
</DialogHeader>
|
||||
<BaseSSOSettingsForm form={form} onFormSubmit={handleFormSubmit} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => form.submit()} disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add SSO"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<BaseSSOSettingsForm />
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add SSO"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,50 @@
|
|||
import { Form } from "antd";
|
||||
import React from "react";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../../../../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import BaseSSOSettingsForm, { renderProviderFields } from "./BaseSSOSettingsForm";
|
||||
|
||||
import { renderWithProviders } from "../../../../../../tests/test-utils";
|
||||
import BaseSSOSettingsForm, {
|
||||
renderProviderFields,
|
||||
SSOSettingsFormValues,
|
||||
} from "./BaseSSOSettingsForm";
|
||||
|
||||
const TestWrapper: React.FC<{
|
||||
defaultValues?: Partial<SSOSettingsFormValues>;
|
||||
onSubmit?: (values: SSOSettingsFormValues) => void;
|
||||
}> = ({ defaultValues, onSubmit }) => {
|
||||
const form = useForm<SSOSettingsFormValues>({
|
||||
defaultValues: { ...defaultValues },
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
onSubmit?.(values);
|
||||
});
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<BaseSSOSettingsForm />
|
||||
<button type="submit">submit</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const selectProvider = async (providerText: RegExp) => {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByRole("combobox", { name: /sso provider/i });
|
||||
await user.click(trigger);
|
||||
const option = await screen.findByRole("option", { name: providerText });
|
||||
await user.click(option);
|
||||
};
|
||||
|
||||
describe("BaseSSOSettingsForm", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
it("should render top-level fields", () => {
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
expect(screen.getByText("SSO Provider")).toBeInTheDocument();
|
||||
|
|
@ -25,24 +53,9 @@ describe("BaseSSOSettingsForm", () => {
|
|||
});
|
||||
|
||||
it("should render provider fields when provider is selected", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const providerSelect = screen.getByLabelText("SSO Provider");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const googleOption = screen.getByText(/google sso/i);
|
||||
fireEvent.click(googleOption);
|
||||
});
|
||||
await selectProvider(/google sso/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Google Client ID")).toBeInTheDocument();
|
||||
|
|
@ -50,25 +63,10 @@ describe("BaseSSOSettingsForm", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should show role mappings fields for okta provider", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
it("should show role mappings checkbox for okta provider", async () => {
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const providerSelect = screen.getByLabelText("SSO Provider");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const oktaOption = screen.getByText(/okta/i);
|
||||
fireEvent.click(oktaOption);
|
||||
});
|
||||
await selectProvider(/okta/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Use Role Mappings")).toBeInTheDocument();
|
||||
|
|
@ -76,75 +74,53 @@ describe("BaseSSOSettingsForm", () => {
|
|||
});
|
||||
|
||||
it("should validate proxy base url format", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText("https://example.com");
|
||||
await act(async () => {
|
||||
fireEvent.change(urlInput, { target: { value: "invalid-url" } });
|
||||
fireEvent.blur(urlInput);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/URL must start with http:\/\/ or https:\/\//i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/URL must start with http:\/\/ or https:\/\//i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should validate proxy base url trailing slash", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText("https://example.com");
|
||||
await act(async () => {
|
||||
fireEvent.change(urlInput, { target: { value: "https://example.com/" } });
|
||||
fireEvent.blur(urlInput);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/URL must not end with a trailing slash/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/URL must not end with a trailing slash/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show role mappings fields when use_role_mappings is checked for generic provider", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const providerSelect = screen.getByLabelText("SSO Provider");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const genericOption = screen.getByText(/generic sso/i);
|
||||
fireEvent.click(genericOption);
|
||||
});
|
||||
await selectProvider(/generic sso/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Use Role Mappings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByLabelText("Use Role Mappings");
|
||||
await act(async () => {
|
||||
fireEvent.click(checkbox);
|
||||
});
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Group Claim")).toBeInTheDocument();
|
||||
|
|
@ -153,24 +129,9 @@ describe("BaseSSOSettingsForm", () => {
|
|||
});
|
||||
|
||||
it("should show team mappings checkbox for okta provider", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const providerSelect = screen.getByLabelText("SSO Provider");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const oktaOption = screen.getByText(/okta/i);
|
||||
fireEvent.click(oktaOption);
|
||||
});
|
||||
await selectProvider(/okta/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Use Team Mappings")).toBeInTheDocument();
|
||||
|
|
@ -178,24 +139,9 @@ describe("BaseSSOSettingsForm", () => {
|
|||
});
|
||||
|
||||
it("should show team mappings checkbox for generic provider", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const providerSelect = screen.getByLabelText("SSO Provider");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const genericOption = screen.getByText(/generic sso/i);
|
||||
fireEvent.click(genericOption);
|
||||
});
|
||||
await selectProvider(/generic sso/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Use Team Mappings")).toBeInTheDocument();
|
||||
|
|
@ -203,33 +149,17 @@ describe("BaseSSOSettingsForm", () => {
|
|||
});
|
||||
|
||||
it("should show team IDs JWT field when use_team_mappings is checked for okta provider", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const providerSelect = screen.getByLabelText("SSO Provider");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const oktaOption = screen.getByText(/okta/i);
|
||||
fireEvent.click(oktaOption);
|
||||
});
|
||||
await selectProvider(/okta/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Use Team Mappings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByLabelText("Use Team Mappings");
|
||||
await act(async () => {
|
||||
fireEvent.click(checkbox);
|
||||
});
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Team IDs JWT Field")).toBeInTheDocument();
|
||||
|
|
@ -237,24 +167,9 @@ describe("BaseSSOSettingsForm", () => {
|
|||
});
|
||||
|
||||
it("should not show team mappings checkbox for google provider", async () => {
|
||||
const TestWrapper = () => {
|
||||
const [form] = Form.useForm();
|
||||
const handleSubmit = vi.fn();
|
||||
|
||||
return <BaseSSOSettingsForm form={form} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
const providerSelect = screen.getByLabelText("SSO Provider");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(providerSelect);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const googleOption = screen.getByText(/google sso/i);
|
||||
fireEvent.click(googleOption);
|
||||
});
|
||||
await selectProvider(/google sso/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Google Client ID")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,23 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
Form,
|
||||
Input as AntInput,
|
||||
Select,
|
||||
} from "antd";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import React from "react";
|
||||
import { ssoProviderLogoMap, ssoProviderDisplayNames } from "../constants";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
export interface BaseSSOSettingsFormProps {
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { ssoProviderDisplayNames, ssoProviderLogoMap } from "../constants";
|
||||
|
||||
export interface SSOSettingsFormValues {
|
||||
sso_provider?: string | null;
|
||||
google_client_id?: string | null;
|
||||
google_client_secret?: string | null;
|
||||
microsoft_client_id?: string | null;
|
||||
microsoft_client_secret?: string | null;
|
||||
microsoft_tenant?: string | null;
|
||||
generic_client_id?: string | null;
|
||||
generic_client_secret?: string | null;
|
||||
generic_authorization_endpoint?: string | null;
|
||||
generic_token_endpoint?: string | null;
|
||||
generic_userinfo_endpoint?: string | null;
|
||||
user_email?: string | null;
|
||||
proxy_base_url?: string | null;
|
||||
use_role_mappings?: boolean;
|
||||
group_claim?: string | null;
|
||||
default_role?: string | null;
|
||||
proxy_admin_teams?: string | null;
|
||||
admin_viewer_teams?: string | null;
|
||||
internal_user_teams?: string | null;
|
||||
internal_viewer_teams?: string | null;
|
||||
use_team_mappings?: boolean;
|
||||
team_ids_jwt_field?: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onFormSubmit: (formValues: Record<string, any>) => Promise<void>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface BaseSSOSettingsFormProps {
|
||||
/**
|
||||
* Retained for API compatibility with previous antd-based callers. The
|
||||
* shadcn/RHF migration reads the form via `useFormContext`; callers now
|
||||
* wrap children in `FormProvider` and do not need to thread `form` down.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onFormSubmit?: (formValues: Record<string, any>) => Promise<void>;
|
||||
}
|
||||
|
||||
// Define the SSO provider configuration type
|
||||
export interface SSOProviderConfig {
|
||||
envVarMap: Record<string, string>;
|
||||
fields: Array<{
|
||||
|
|
@ -27,7 +70,6 @@ export interface SSOProviderConfig {
|
|||
}>;
|
||||
}
|
||||
|
||||
// Define configurations for each SSO provider
|
||||
export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
||||
google: {
|
||||
envVarMap: {
|
||||
|
|
@ -67,7 +109,11 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
name: "generic_authorization_endpoint",
|
||||
placeholder: "https://your-domain/authorize",
|
||||
},
|
||||
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
|
||||
{
|
||||
label: "Token Endpoint",
|
||||
name: "generic_token_endpoint",
|
||||
placeholder: "https://your-domain/token",
|
||||
},
|
||||
{
|
||||
label: "Userinfo Endpoint",
|
||||
name: "generic_userinfo_endpoint",
|
||||
|
|
@ -93,214 +139,312 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
},
|
||||
};
|
||||
|
||||
// Helper function to render provider fields
|
||||
/**
|
||||
* Pure helper kept for call sites that render provider fields outside of
|
||||
* the full base form (e.g. `SSOModals.tsx`). The returned elements assume
|
||||
* an ambient `FormProvider` context, matching the parent form's RHF wiring.
|
||||
*/
|
||||
export const renderProviderFields = (provider: string) => {
|
||||
const config = ssoProviderConfigs[provider];
|
||||
if (!config) return null;
|
||||
|
||||
return config.fields.map((field) => (
|
||||
<Form.Item
|
||||
<ProviderField
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
|
||||
>
|
||||
{field.name.includes("client") ? (
|
||||
<AntInput.Password />
|
||||
) : (
|
||||
<Input placeholder={field.placeholder} />
|
||||
)}
|
||||
</Form.Item>
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const BaseSSOSettingsForm: React.FC<BaseSSOSettingsFormProps> = ({ form, onFormSubmit }) => {
|
||||
const ProviderField: React.FC<{
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
}> = ({ label, name, placeholder }) => {
|
||||
const { control } = useFormContext();
|
||||
const isSecret = name.includes("client_secret");
|
||||
return (
|
||||
<div>
|
||||
<Form form={form} onFinish={onFormSubmit} 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 className="flex items-center py-1">
|
||||
{logo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logo}
|
||||
alt={value}
|
||||
className="h-6 w-6 mr-3 object-contain"
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{ssoProviderDisplayNames[value] || value.charAt(0).toUpperCase() + value.slice(1) + " SSO"}
|
||||
</span>
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
rules={{ required: `Please enter the ${label.toLowerCase()}` }}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">{label}</FormLabel>
|
||||
<div className="space-y-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
type={isSecret ? "password" : "text"}
|
||||
placeholder={placeholder}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseSSOSettingsForm: React.FC<BaseSSOSettingsFormProps> = () => {
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
const provider = useWatch({ control, name: "sso_provider" });
|
||||
const useRoleMappings = useWatch({ control, name: "use_role_mappings" });
|
||||
const useTeamMappings = useWatch({ control, name: "use_team_mappings" });
|
||||
const supportsRoleMappings = provider === "okta" || provider === "generic";
|
||||
const supportsTeamMappings = provider === "okta" || provider === "generic";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="sso_provider"
|
||||
rules={{ required: "Please select an SSO provider" }}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">SSO Provider</FormLabel>
|
||||
<div className="space-y-1">
|
||||
<Select value={field.value ?? ""} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(ssoProviderLogoMap).map(([value, logo]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex items-center py-1">
|
||||
{logo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logo}
|
||||
alt={value}
|
||||
className="h-6 w-6 mr-3 object-contain"
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{ssoProviderDisplayNames[value] ||
|
||||
value.charAt(0).toUpperCase() + value.slice(1) + " SSO"}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{provider ? renderProviderFields(provider) : null}
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="user_email"
|
||||
rules={{ required: "Please enter the email of the proxy admin" }}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Proxy Admin Email</FormLabel>
|
||||
<div className="space-y-1">
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="proxy_base_url"
|
||||
rules={{
|
||||
required: "Please enter the proxy base url",
|
||||
validate: (value) => {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return "Please enter the proxy base url";
|
||||
if (!/^https?:\/\/.+/.test(trimmed)) {
|
||||
return "URL must start with http:// or https://";
|
||||
}
|
||||
if (trimmed.endsWith("/")) {
|
||||
return "URL must not end with a trailing slash";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Proxy Base URL</FormLabel>
|
||||
<div className="space-y-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{supportsRoleMappings && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="use_role_mappings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-center gap-4 space-y-0">
|
||||
<FormLabel htmlFor="use_role_mappings">Use Role Mappings</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="use_role_mappings"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{useRoleMappings && supportsRoleMappings && (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="group_claim"
|
||||
rules={{ required: "Please enter the group claim" }}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Group Claim</FormLabel>
|
||||
<div className="space-y-1">
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="default_role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Default Role</FormLabel>
|
||||
<Select value={field.value ?? ""} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a default role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<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>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="proxy_admin_teams"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Proxy Admin Teams</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="admin_viewer_teams"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Admin Viewer Teams</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="internal_user_teams"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Internal User Teams</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="internal_viewer_teams"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Internal Viewer Teams</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{supportsTeamMappings && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="use_team_mappings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-center gap-4 space-y-0">
|
||||
<FormLabel htmlFor="use_team_mappings">Use Team Mappings</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="use_team_mappings"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Proxy Admin Email"
|
||||
name="user_email"
|
||||
rules={[{ required: true, message: "Please enter the email of the proxy admin" }]}
|
||||
>
|
||||
<Input />
|
||||
</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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input 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 ||
|
||||
prevValues.sso_provider !== currentValues.sso_provider
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const useRoleMappings = getFieldValue("use_role_mappings");
|
||||
const provider = getFieldValue("sso_provider");
|
||||
const supportsRoleMappings = provider === "okta" || provider === "generic";
|
||||
return useRoleMappings && supportsRoleMappings ? (
|
||||
<Form.Item
|
||||
label="Group Claim"
|
||||
name="group_claim"
|
||||
rules={[{ required: true, message: "Please enter the group claim" }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.use_role_mappings !== currentValues.use_role_mappings ||
|
||||
prevValues.sso_provider !== currentValues.sso_provider
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const useRoleMappings = getFieldValue("use_role_mappings");
|
||||
const provider = getFieldValue("sso_provider");
|
||||
const supportsRoleMappings = provider === "okta" || provider === "generic";
|
||||
return useRoleMappings && supportsRoleMappings ? (
|
||||
<>
|
||||
<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">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Admin Viewer Teams" name="admin_viewer_teams">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Internal User Teams" name="internal_user_teams">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Internal Viewer Teams" name="internal_viewer_teams">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null;
|
||||
}}
|
||||
</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 Team Mappings" name="use_team_mappings" valuePropName="checked">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.use_team_mappings !== currentValues.use_team_mappings ||
|
||||
prevValues.sso_provider !== currentValues.sso_provider
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const useTeamMappings = getFieldValue("use_team_mappings");
|
||||
const provider = getFieldValue("sso_provider");
|
||||
const supportsTeamMappings = provider === "okta" || provider === "generic";
|
||||
return useTeamMappings && supportsTeamMappings ? (
|
||||
<Form.Item
|
||||
label="Team IDs JWT Field"
|
||||
name="team_ids_jwt_field"
|
||||
rules={[{ required: true, message: "Please enter the team IDs JWT field" }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{useTeamMappings && supportsTeamMappings && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="team_ids_jwt_field"
|
||||
rules={{ required: "Please enter the team IDs JWT field" }}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-[8rem_1fr] items-start gap-4 space-y-0">
|
||||
<FormLabel className="pt-2">Team IDs JWT Field</FormLabel>
|
||||
<div className="space-y-1">
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
|
||||
import EditSSOSettingsModal from "./EditSSOSettingsModal";
|
||||
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
|
||||
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
|
||||
|
|
@ -7,7 +10,6 @@ import NotificationsManager from "@/components/molecules/notifications_manager";
|
|||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import { processSSOSettingsPayload } from "../utils";
|
||||
|
||||
// Constants
|
||||
const SSO_PROVIDERS = {
|
||||
GOOGLE: "google",
|
||||
MICROSOFT: "microsoft",
|
||||
|
|
@ -18,7 +20,6 @@ const SSO_PROVIDERS = {
|
|||
|
||||
const TEST_DATA = {
|
||||
MODAL_TITLE: "Edit SSO Settings",
|
||||
MODAL_WIDTH: "800",
|
||||
SUCCESS_MESSAGE: "SSO settings updated successfully",
|
||||
ERROR_MESSAGE_PREFIX: "Failed to save SSO settings:",
|
||||
BUTTON_TEXT: {
|
||||
|
|
@ -28,29 +29,19 @@ const TEST_DATA = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
const TEST_IDS = {
|
||||
MODAL: "modal",
|
||||
BUTTON: "button",
|
||||
BASE_SSO_FORM: "base-sso-form",
|
||||
TRIGGER_FORM_SUBMIT: "trigger-form-submit",
|
||||
} as const;
|
||||
const TRIGGER_SUBMIT_TEST_ID = "trigger-form-submit";
|
||||
const FORM_VALUES_TEST_ID = "captured-form-values";
|
||||
|
||||
// Mock form instance
|
||||
const mockForm = {
|
||||
resetFields: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
getFieldsValue: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
};
|
||||
|
||||
// Types
|
||||
type SSOData = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
values: Record<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} & Record<string, any>;
|
||||
|
||||
type SSOSettingsHookReturn = {
|
||||
data: SSOData | null;
|
||||
isLoading: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any;
|
||||
};
|
||||
|
||||
|
|
@ -59,7 +50,7 @@ type EditSSOSettingsHookReturn = {
|
|||
isPending: boolean;
|
||||
};
|
||||
|
||||
// Test data factories
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createSSOData = (overrides: Record<string, any> = {}): SSOData => ({
|
||||
values: {
|
||||
user_email: "test@example.com",
|
||||
|
|
@ -67,6 +58,7 @@ const createSSOData = (overrides: Record<string, any> = {}): SSOData => ({
|
|||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createGoogleSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createSSOData({
|
||||
google_client_id: "test-google-id",
|
||||
|
|
@ -74,6 +66,7 @@ const createGoogleSSOData = (overrides: Record<string, any> = {}) =>
|
|||
...overrides,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createMicrosoftSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createSSOData({
|
||||
microsoft_client_id: "test-microsoft-id",
|
||||
|
|
@ -82,14 +75,17 @@ const createMicrosoftSSOData = (overrides: Record<string, any> = {}) =>
|
|||
...overrides,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createGenericSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createSSOData({
|
||||
generic_client_id: "test-generic-id",
|
||||
generic_client_secret: "test-generic-secret",
|
||||
generic_authorization_endpoint: overrides.authorization_endpoint || "https://custom.example.com/oauth",
|
||||
generic_authorization_endpoint:
|
||||
overrides.authorization_endpoint || "https://custom.example.com/oauth",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createRoleMappingsSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createGoogleSSOData({
|
||||
role_mappings: {
|
||||
|
|
@ -105,6 +101,7 @@ const createRoleMappingsSSOData = (overrides: Record<string, any> = {}) =>
|
|||
...overrides,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createTeamMappingsSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createGenericSSOData({
|
||||
team_mappings: {
|
||||
|
|
@ -113,7 +110,6 @@ const createTeamMappingsSSOData = (overrides: Record<string, any> = {}) =>
|
|||
...overrides,
|
||||
});
|
||||
|
||||
// Mock utilities
|
||||
const createMockHooks = (): {
|
||||
useSSOSettings: SSOSettingsHookReturn;
|
||||
useEditSSOSettings: EditSSOSettingsHookReturn;
|
||||
|
|
@ -129,38 +125,22 @@ const createMockHooks = (): {
|
|||
},
|
||||
});
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
Modal: ({ children, open, title, footer, onCancel, width, ...props }: any) => (
|
||||
<div data-testid={TEST_IDS.MODAL} data-open={open} data-title={title} data-width={width} {...props}>
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
<div data-testid="modal-footer">{footer}</div>
|
||||
<button data-testid="modal-cancel" onClick={onCancel} />
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, loading, disabled, ...props }: any) => (
|
||||
<button data-testid={TEST_IDS.BUTTON} onClick={onClick} data-loading={loading} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Form: {
|
||||
useForm: () => [mockForm],
|
||||
},
|
||||
Space: ({ children, ...props }: any) => (
|
||||
<div data-testid="space" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./BaseSSOSettingsForm", () => ({
|
||||
default: ({ form, onFormSubmit }: any) => (
|
||||
<div data-testid={TEST_IDS.BASE_SSO_FORM}>
|
||||
<button data-testid={TEST_IDS.TRIGGER_FORM_SUBMIT} onClick={() => onFormSubmit({ testField: "testValue" })}>
|
||||
Trigger Form Submit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
// Replaces the real BaseSSOSettingsForm with a spy that:
|
||||
// 1. exposes all RHF form values via a hidden JSON blob for assertion
|
||||
// 2. provides a test-id button that submits the parent form so we can
|
||||
// exercise the submit path without rendering the full provider UI.
|
||||
vi.mock("./BaseSSOSettingsForm", () => {
|
||||
const Mock = () => {
|
||||
const ctx = useFormContext();
|
||||
const values = ctx.watch();
|
||||
return (
|
||||
<div data-testid="base-sso-form">
|
||||
<span data-testid={FORM_VALUES_TEST_ID}>{JSON.stringify(values)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return { default: Mock };
|
||||
});
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/sso/useSSOSettings", () => ({
|
||||
useSSOSettings: vi.fn(),
|
||||
|
|
@ -185,7 +165,6 @@ vi.mock("../utils", () => ({
|
|||
processSSOSettingsPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
// Test helpers
|
||||
const setupMocks = (
|
||||
overrides: Partial<{
|
||||
useSSOSettings: Partial<SSOSettingsHookReturn>;
|
||||
|
|
@ -194,17 +173,25 @@ const setupMocks = (
|
|||
) => {
|
||||
const defaultMocks = createMockHooks();
|
||||
const mocks = {
|
||||
useSSOSettings: { ...defaultMocks.useSSOSettings, ...overrides.useSSOSettings },
|
||||
useEditSSOSettings: { ...defaultMocks.useEditSSOSettings, ...overrides.useEditSSOSettings },
|
||||
useSSOSettings: {
|
||||
...defaultMocks.useSSOSettings,
|
||||
...overrides.useSSOSettings,
|
||||
},
|
||||
useEditSSOSettings: {
|
||||
...defaultMocks.useEditSSOSettings,
|
||||
...overrides.useEditSSOSettings,
|
||||
},
|
||||
};
|
||||
|
||||
(useSSOSettings as Mock).mockReturnValue(mocks.useSSOSettings);
|
||||
(useEditSSOSettings as Mock).mockReturnValue(mocks.useEditSSOSettings);
|
||||
(useSSOSettings as unknown as Mock).mockReturnValue(mocks.useSSOSettings);
|
||||
(useEditSSOSettings as unknown as Mock).mockReturnValue(mocks.useEditSSOSettings);
|
||||
|
||||
return mocks;
|
||||
};
|
||||
|
||||
const renderComponent = (props: Partial<React.ComponentProps<typeof EditSSOSettingsModal>> = {}) => {
|
||||
const renderComponent = (
|
||||
props: Partial<React.ComponentProps<typeof EditSSOSettingsModal>> = {},
|
||||
) => {
|
||||
const defaultProps = {
|
||||
isVisible: true,
|
||||
onCancel: vi.fn(),
|
||||
|
|
@ -218,9 +205,22 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof EditSSOSetti
|
|||
};
|
||||
};
|
||||
|
||||
const getButtons = () => screen.getAllByTestId(TEST_IDS.BUTTON);
|
||||
const getCancelButton = () => getButtons()[0];
|
||||
const getSaveButton = () => getButtons()[1];
|
||||
const getCancelButton = () =>
|
||||
screen.getByRole("button", { name: TEST_DATA.BUTTON_TEXT.CANCEL });
|
||||
const getSaveButton = () =>
|
||||
screen.getByRole("button", {
|
||||
name: new RegExp(
|
||||
`^(${TEST_DATA.BUTTON_TEXT.SAVE}|${TEST_DATA.BUTTON_TEXT.SAVING})$`,
|
||||
),
|
||||
});
|
||||
|
||||
// Reads the JSON blob that the mocked BaseSSOSettingsForm renders.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getCapturedFormValues = (): Record<string, any> => {
|
||||
const el = screen.queryByTestId(FORM_VALUES_TEST_ID);
|
||||
if (!el) return {};
|
||||
return JSON.parse(el.textContent || "{}");
|
||||
};
|
||||
|
||||
describe("EditSSOSettingsModal", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -229,56 +229,42 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders without crashing", () => {
|
||||
it("should render without crashing", () => {
|
||||
expect(() => renderComponent()).not.toThrow();
|
||||
});
|
||||
|
||||
it("displays modal with correct configuration", () => {
|
||||
it("should display dialog with correct title when visible", () => {
|
||||
renderComponent();
|
||||
|
||||
const modal = screen.getByTestId(TEST_IDS.MODAL);
|
||||
expect(modal).toHaveAttribute("data-open", "true");
|
||||
expect(modal).toHaveAttribute("data-title", TEST_DATA.MODAL_TITLE);
|
||||
expect(modal).toHaveAttribute("data-width", TEST_DATA.MODAL_WIDTH);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText(TEST_DATA.MODAL_TITLE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays modal as closed when not visible", () => {
|
||||
it("should not render dialog when not visible", () => {
|
||||
renderComponent({ isVisible: false });
|
||||
|
||||
const modal = screen.getByTestId(TEST_IDS.MODAL);
|
||||
expect(modal).toHaveAttribute("data-open", "false");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Actions", () => {
|
||||
it("renders cancel and save buttons", () => {
|
||||
it("should render cancel and save buttons", () => {
|
||||
renderComponent();
|
||||
|
||||
const buttons = getButtons();
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0]).toHaveTextContent(TEST_DATA.BUTTON_TEXT.CANCEL);
|
||||
expect(buttons[1]).toHaveTextContent(TEST_DATA.BUTTON_TEXT.SAVE);
|
||||
expect(getCancelButton()).toBeInTheDocument();
|
||||
expect(getSaveButton()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCancel and resets form when cancel button is clicked", () => {
|
||||
it("should call onCancel when cancel button is clicked", () => {
|
||||
const { mockOnCancel } = renderComponent();
|
||||
|
||||
fireEvent.click(getCancelButton());
|
||||
|
||||
expect(mockForm.resetFields).toHaveBeenCalled();
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls form.submit when save button is clicked", () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
expect(mockForm.submit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Loading States", () => {
|
||||
it("disables cancel button during submission", () => {
|
||||
it("should disable cancel button during submission", () => {
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: vi.fn(), isPending: true },
|
||||
});
|
||||
|
|
@ -288,97 +274,150 @@ describe("EditSSOSettingsModal", () => {
|
|||
expect(getCancelButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows loading state on save button during submission", () => {
|
||||
it("should show Saving text on save button during submission", () => {
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: vi.fn(), isPending: true },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(getSaveButton()).toHaveAttribute("data-loading", "true");
|
||||
expect(getSaveButton()).toHaveTextContent(TEST_DATA.BUTTON_TEXT.SAVING);
|
||||
expect(
|
||||
screen.getByRole("button", { name: TEST_DATA.BUTTON_TEXT.SAVING }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Submission", () => {
|
||||
const formValues = { testField: "testValue" };
|
||||
const processedPayload = { processed: "payload" };
|
||||
|
||||
beforeEach(() => {
|
||||
(processSSOSettingsPayload as any).mockReturnValue(processedPayload);
|
||||
});
|
||||
|
||||
it("processes form values and submits successfully", async () => {
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onSuccess();
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
});
|
||||
|
||||
const { mockOnSuccess } = renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalledWith(formValues);
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
(processSSOSettingsPayload as unknown as Mock).mockReturnValue(
|
||||
processedPayload,
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows success notification and calls onSuccess callback", async () => {
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
it("should process form values and submits successfully", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload: any, options: any) => {
|
||||
options.onSuccess();
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
useSSOSettings: {
|
||||
data: createGoogleSSOData(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
useEditSSOSettings: {
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { mockOnSuccess } = renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith(TEST_DATA.SUCCESS_MESSAGE);
|
||||
expect(mockOnSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles submission errors gracefully", async () => {
|
||||
const error = new Error("Submission failed");
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onError(error);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
});
|
||||
|
||||
(parseErrorMessage as any).mockReturnValue("Parsed error message");
|
||||
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
await waitFor(() => {
|
||||
expect(getCapturedFormValues().sso_provider).toBe(SSO_PROVIDERS.GOOGLE);
|
||||
});
|
||||
|
||||
expect(parseErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
`${TEST_DATA.ERROR_MESSAGE_PREFIX} Parsed error message`,
|
||||
);
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalled();
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
processedPayload,
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show success notification and calls onSuccess callback", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload: any, options: any) => {
|
||||
options.onSuccess();
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: {
|
||||
data: createGoogleSSOData(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
useEditSSOSettings: {
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { mockOnSuccess } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCapturedFormValues().sso_provider).toBe(SSO_PROVIDERS.GOOGLE);
|
||||
});
|
||||
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith(
|
||||
TEST_DATA.SUCCESS_MESSAGE,
|
||||
);
|
||||
expect(mockOnSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle submission errors gracefully", async () => {
|
||||
const error = new Error("Submission failed");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload: any, options: any) => {
|
||||
options.onError(error);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: {
|
||||
data: createGoogleSSOData(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
useEditSSOSettings: {
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
(parseErrorMessage as unknown as Mock).mockReturnValue("Parsed error message");
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCapturedFormValues().sso_provider).toBe(SSO_PROVIDERS.GOOGLE);
|
||||
});
|
||||
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(parseErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
`${TEST_DATA.ERROR_MESSAGE_PREFIX} Parsed error message`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Initialization", () => {
|
||||
describe("Provider Detection", () => {
|
||||
const testProviderDetection = (testName: string, ssoData: SSOData, expectedProvider: string) => {
|
||||
it(`detects ${testName} provider`, async () => {
|
||||
const testProviderDetection = (
|
||||
testName: string,
|
||||
ssoData: SSOData,
|
||||
expectedProvider: string,
|
||||
) => {
|
||||
it(`should detect ${testName} provider`, async () => {
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
|
@ -386,7 +425,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: expectedProvider,
|
||||
...ssoData.values,
|
||||
});
|
||||
|
|
@ -394,9 +433,17 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
};
|
||||
|
||||
testProviderDetection("Google", createGoogleSSOData(), SSO_PROVIDERS.GOOGLE);
|
||||
testProviderDetection(
|
||||
"Google",
|
||||
createGoogleSSOData(),
|
||||
SSO_PROVIDERS.GOOGLE,
|
||||
);
|
||||
|
||||
testProviderDetection("Microsoft", createMicrosoftSSOData(), SSO_PROVIDERS.MICROSOFT);
|
||||
testProviderDetection(
|
||||
"Microsoft",
|
||||
createMicrosoftSSOData(),
|
||||
SSO_PROVIDERS.MICROSOFT,
|
||||
);
|
||||
|
||||
testProviderDetection(
|
||||
"Okta",
|
||||
|
|
@ -411,14 +458,18 @@ describe("EditSSOSettingsModal", () => {
|
|||
createGenericSSOData({
|
||||
authorization_endpoint: "https://auth0.example.com/authorize",
|
||||
}),
|
||||
SSO_PROVIDERS.OKTA, // Auth0 URLs are detected as Okta provider
|
||||
SSO_PROVIDERS.OKTA,
|
||||
);
|
||||
|
||||
testProviderDetection("generic", createGenericSSOData(), SSO_PROVIDERS.GENERIC);
|
||||
testProviderDetection(
|
||||
"generic",
|
||||
createGenericSSOData(),
|
||||
SSO_PROVIDERS.GENERIC,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Role Mappings", () => {
|
||||
it("processes role mappings with all roles assigned", async () => {
|
||||
it("should process role mappings with all roles assigned", async () => {
|
||||
const ssoData = createRoleMappingsSSOData();
|
||||
|
||||
setupMocks({
|
||||
|
|
@ -428,7 +479,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: SSO_PROVIDERS.GOOGLE,
|
||||
...ssoData.values,
|
||||
use_role_mappings: true,
|
||||
|
|
@ -442,7 +493,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("handles empty role mapping arrays", async () => {
|
||||
it("should handle empty role mapping arrays", async () => {
|
||||
const ssoData = createRoleMappingsSSOData({
|
||||
proxy_admin: [],
|
||||
proxy_admin_viewer: [],
|
||||
|
|
@ -456,7 +507,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: SSO_PROVIDERS.GOOGLE,
|
||||
...ssoData.values,
|
||||
use_role_mappings: true,
|
||||
|
|
@ -472,22 +523,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
|
||||
describe("Initialization Guards", () => {
|
||||
it("resets form before setting values", async () => {
|
||||
const ssoData = createGoogleSSOData();
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.resetFields).toHaveBeenCalled();
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips initialization when modal is not visible", () => {
|
||||
it("should skip initialization when modal is not visible", async () => {
|
||||
const ssoData = createGoogleSSOData();
|
||||
|
||||
setupMocks({
|
||||
|
|
@ -496,72 +532,108 @@ describe("EditSSOSettingsModal", () => {
|
|||
|
||||
renderComponent({ isVisible: false });
|
||||
|
||||
expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
|
||||
// Dialog not rendered, no values captured
|
||||
expect(screen.queryByTestId(FORM_VALUES_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips initialization when SSO data is unavailable", () => {
|
||||
it("should skip initialization when SSO data is unavailable", async () => {
|
||||
setupMocks({
|
||||
useSSOSettings: { data: null, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
|
||||
// Dialog renders, but form state contains no provider-derived fields.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(FORM_VALUES_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
const captured = getCapturedFormValues();
|
||||
expect(captured.sso_provider).toBeUndefined();
|
||||
expect(captured.google_client_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("handles form submission errors with undefined error message", async () => {
|
||||
it("should handle form submission errors with undefined error message", async () => {
|
||||
const error = new Error("Network error");
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload: any, options: any) => {
|
||||
options.onError(error);
|
||||
return Promise.reject(error);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
(processSSOSettingsPayload as unknown as Mock).mockReturnValue({});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
useSSOSettings: {
|
||||
data: createGoogleSSOData(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
useEditSSOSettings: {
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
(parseErrorMessage as any).mockReturnValue(undefined);
|
||||
(parseErrorMessage as unknown as Mock).mockReturnValue(undefined);
|
||||
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
await waitFor(() => {
|
||||
expect(getCapturedFormValues().sso_provider).toBe(SSO_PROVIDERS.GOOGLE);
|
||||
});
|
||||
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(`${TEST_DATA.ERROR_MESSAGE_PREFIX} undefined`);
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
`${TEST_DATA.ERROR_MESSAGE_PREFIX} undefined`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles form submission with malformed data", async () => {
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onError(new Error("Invalid data"));
|
||||
return Promise.reject(new Error("Invalid data"));
|
||||
});
|
||||
it("should handle form submission with malformed data", async () => {
|
||||
const mockMutateAsync = vi.fn();
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
useSSOSettings: {
|
||||
data: createGoogleSSOData(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
useEditSSOSettings: {
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
(processSSOSettingsPayload as any).mockImplementation(() => {
|
||||
(processSSOSettingsPayload as unknown as Mock).mockImplementation(() => {
|
||||
throw new Error("Processing failed");
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
await waitFor(() => {
|
||||
expect(getCapturedFormValues().sso_provider).toBe(SSO_PROVIDERS.GOOGLE);
|
||||
});
|
||||
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalled();
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles role mappings with undefined roles object", async () => {
|
||||
it("should handle role mappings with undefined roles object", async () => {
|
||||
const ssoData = createGoogleSSOData({
|
||||
role_mappings: {
|
||||
group_claim: "groups",
|
||||
default_role: "internal_user",
|
||||
// roles is undefined
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -572,7 +644,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: SSO_PROVIDERS.GOOGLE,
|
||||
...ssoData.values,
|
||||
use_role_mappings: true,
|
||||
|
|
@ -588,7 +660,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
|
||||
describe("Team Mappings", () => {
|
||||
it("processes team mappings when team_mappings exists", async () => {
|
||||
it("should process team mappings when team_mappings exists", async () => {
|
||||
const ssoData = createTeamMappingsSSOData();
|
||||
|
||||
setupMocks({
|
||||
|
|
@ -598,7 +670,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: SSO_PROVIDERS.GENERIC,
|
||||
...ssoData.values,
|
||||
use_team_mappings: true,
|
||||
|
|
@ -607,7 +679,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("handles team mappings with custom JWT field name", async () => {
|
||||
it("should handle team mappings with custom JWT field name", async () => {
|
||||
const ssoData = createTeamMappingsSSOData({
|
||||
team_ids_jwt_field: "custom_teams_field",
|
||||
});
|
||||
|
|
@ -619,7 +691,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: SSO_PROVIDERS.GENERIC,
|
||||
...ssoData.values,
|
||||
use_team_mappings: true,
|
||||
|
|
@ -628,7 +700,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("handles team mappings and role mappings together", async () => {
|
||||
it("should handle team mappings and role mappings together", async () => {
|
||||
const ssoData = createGenericSSOData({
|
||||
role_mappings: {
|
||||
group_claim: "groups",
|
||||
|
|
@ -652,7 +724,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: SSO_PROVIDERS.GENERIC,
|
||||
...ssoData.values,
|
||||
use_role_mappings: true,
|
||||
|
|
@ -668,7 +740,7 @@ describe("EditSSOSettingsModal", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not set team mapping fields when team_mappings is not present", async () => {
|
||||
it("should not set team mapping fields when team_mappings is not present", async () => {
|
||||
const ssoData = createGenericSSOData();
|
||||
|
||||
setupMocks({
|
||||
|
|
@ -678,15 +750,15 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const callArgs = mockForm.setFieldsValue.mock.calls[0][0];
|
||||
expect(callArgs.use_team_mappings).toBeUndefined();
|
||||
expect(callArgs.team_ids_jwt_field).toBeUndefined();
|
||||
const captured = getCapturedFormValues();
|
||||
expect(captured.sso_provider).toBe(SSO_PROVIDERS.GENERIC);
|
||||
expect(captured.use_team_mappings).toBeUndefined();
|
||||
expect(captured.team_ids_jwt_field).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles provider detection with partial SSO data", async () => {
|
||||
it("should handle provider detection with partial SSO data", async () => {
|
||||
const ssoData = createSSOData({
|
||||
// Only has generic fields, no specific provider identifiers
|
||||
generic_client_id: "test-id",
|
||||
generic_authorization_endpoint: "https://unknown.provider.com/auth",
|
||||
});
|
||||
|
|
@ -698,29 +770,40 @@ describe("EditSSOSettingsModal", () => {
|
|||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
expect(getCapturedFormValues()).toMatchObject({
|
||||
sso_provider: SSO_PROVIDERS.GENERIC,
|
||||
...ssoData.values,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("handles form submission when processing throws error", async () => {
|
||||
it("should handle form submission when processing throws error", async () => {
|
||||
setupMocks({
|
||||
useSSOSettings: {
|
||||
data: createGoogleSSOData(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
useEditSSOSettings: { mutateAsync: vi.fn(), isPending: false },
|
||||
});
|
||||
|
||||
(processSSOSettingsPayload as any).mockImplementation(() => {
|
||||
(processSSOSettingsPayload as unknown as Mock).mockImplementation(() => {
|
||||
throw new Error("Processing error");
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCapturedFormValues().sso_provider).toBe(SSO_PROVIDERS.GOOGLE);
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
fireEvent.click(getSaveButton());
|
||||
}).not.toThrow();
|
||||
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -8,14 +13,11 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Form } from "antd";
|
||||
import React, { useEffect } from "react";
|
||||
import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import { processSSOSettingsPayload } from "../utils";
|
||||
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
|
||||
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
|
||||
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
|
||||
|
||||
import BaseSSOSettingsForm, { SSOSettingsFormValues } from "./BaseSSOSettingsForm";
|
||||
import { processSSOSettingsPayload } from "../utils";
|
||||
|
||||
interface EditSSOSettingsModalProps {
|
||||
isVisible: boolean;
|
||||
|
|
@ -23,24 +25,31 @@ interface EditSSOSettingsModalProps {
|
|||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const emptyValues: SSOSettingsFormValues = {};
|
||||
|
||||
const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({
|
||||
isVisible,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const form = useForm<SSOSettingsFormValues>({
|
||||
defaultValues: emptyValues,
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
// Use react-query hooks for SSO settings
|
||||
const ssoSettings = useSSOSettings();
|
||||
const { mutateAsync, isPending } = useEditSSOSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && ssoSettings.data && ssoSettings.data.values) {
|
||||
const ssoData = ssoSettings.data;
|
||||
|
||||
// Determine which SSO provider is configured
|
||||
let selectedProvider = null;
|
||||
let selectedProvider: string | undefined = undefined;
|
||||
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")
|
||||
|
|
@ -51,12 +60,10 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
}
|
||||
}
|
||||
|
||||
// Extract role mappings if they exist
|
||||
let roleMappingFields = {};
|
||||
let roleMappingFields: Partial<SSOSettingsFormValues> = {};
|
||||
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(", ");
|
||||
|
|
@ -73,8 +80,7 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
};
|
||||
}
|
||||
|
||||
// Extract team mappings if they exist
|
||||
let teamMappingFields = {};
|
||||
let teamMappingFields: Partial<SSOSettingsFormValues> = {};
|
||||
if (ssoData.values.team_mappings) {
|
||||
const teamMappings = ssoData.values.team_mappings;
|
||||
teamMappingFields = {
|
||||
|
|
@ -83,25 +89,19 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
};
|
||||
}
|
||||
|
||||
// Set form values with existing data (excluding UI access control fields)
|
||||
const formValues = {
|
||||
const formValues: SSOSettingsFormValues = {
|
||||
sso_provider: selectedProvider,
|
||||
...ssoData.values,
|
||||
...roleMappingFields,
|
||||
...teamMappingFields,
|
||||
};
|
||||
|
||||
// Clear form first, then set values with a small delay to ensure proper initialization
|
||||
form.resetFields();
|
||||
setTimeout(() => {
|
||||
form.setFieldsValue(formValues);
|
||||
}, 100);
|
||||
form.reset(formValues);
|
||||
}
|
||||
}, [isVisible, ssoSettings.data, form]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVisible, ssoSettings.data]);
|
||||
|
||||
// Enhanced form submission handler
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleFormSubmit = async (formValues: Record<string, any>) => {
|
||||
const onSubmit = form.handleSubmit(async (formValues) => {
|
||||
try {
|
||||
const payload = processSSOSettingsPayload(formValues);
|
||||
|
||||
|
|
@ -111,17 +111,20 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to save SSO settings: " + parseErrorMessage(error),
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle processing errors gracefully
|
||||
NotificationsManager.fromBackend("Failed to process SSO settings: " + parseErrorMessage(error));
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to process SSO settings: " + parseErrorMessage(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
form.reset(emptyValues);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
|
@ -134,15 +137,24 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
<DialogHeader>
|
||||
<DialogTitle>Edit SSO Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<BaseSSOSettingsForm form={form} onFormSubmit={handleFormSubmit} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => form.submit()} disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<BaseSSOSettingsForm />
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue