feat(ui): migrate SSO settings modals to shadcn

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
cursor 2026-04-24 06:29:50 +00:00
parent 7e0f12d847
commit 5b15606577
No known key found for this signature in database
6 changed files with 799 additions and 626 deletions

View file

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

View file

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

View file

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

View file

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

View file

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