Edit path for SSO Settings

This commit is contained in:
yuneng-jiang
2026-01-03 12:36:26 -08:00
parent 969790c463
commit 4904ed394e
11 changed files with 894 additions and 245 deletions
@@ -0,0 +1,38 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { updateSSOSettings } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export interface EditSSOSettingsParams {
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;
proxy_base_url?: string | null;
user_email?: string | null;
sso_provider?: string | null;
role_mappings?: any;
[key: string]: any;
}
export interface EditSSOSettingsResponse {
[key: string]: any;
}
export const useEditSSOSettings = (): UseMutationResult<EditSSOSettingsResponse, Error, EditSSOSettingsParams> => {
const { accessToken } = useAuthorized();
return useMutation<EditSSOSettingsResponse, Error, EditSSOSettingsParams>({
mutationFn: async (params: EditSSOSettingsParams) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return await updateSSOSettings(accessToken, params);
},
});
};
@@ -27,7 +27,14 @@ export interface SSOSettingsValues {
proxy_base_url: string | null;
user_email: string | null;
ui_access_mode: string | null;
role_mappings: string | null;
role_mappings: {
provider: string;
group_claim: string;
default_role: "internal_user" | "internal_user_viewer" | "proxy_admin" | "proxy_admin_viewer";
roles: {
[key: string]: string[];
};
};
}
export interface SSOSettingsResponse {
@@ -17,7 +17,7 @@ describe("AddSSOSettingsModal", () => {
const onCancel = vi.fn();
const onSuccess = vi.fn();
render(<AddSSOSettingsModal isVisible={true} onCancel={onCancel} onSuccess={onSuccess} accessToken="test-token" />);
render(<AddSSOSettingsModal isVisible={true} onCancel={onCancel} onSuccess={onSuccess} />);
expect(screen.getByText("SSO Provider")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -1,145 +1,36 @@
"use client";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { updateSSOSettings } from "@/components/networking";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { TextInput } from "@tremor/react";
import { Button as Button2, Form, Input, Modal, Select } from "antd";
import React, { useState } from "react";
import { Button, Form, Modal, Space } from "antd";
import React from "react";
import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
import { processSSOSettingsPayload } from "../utils";
interface AddSSOSettingsModalProps {
isVisible: boolean;
onCancel: () => void;
onSuccess: () => void;
accessToken: string | null;
}
const ssoProviderLogoMap: Record<string, string> = {
google: "https://artificialanalysis.ai/img/logos/google_small.svg",
microsoft: "https://upload.wikimedia.org/wikipedia/commons/a/a8/Microsoft_Azure_Logo.svg",
okta: "https://www.okta.com/sites/default/files/Okta_Logo_BrightBlue_Medium.png",
generic: "",
};
// Define the SSO provider configuration type
interface SSOProviderConfig {
envVarMap: Record<string, string>;
fields: Array<{
label: string;
name: string;
placeholder?: string;
}>;
}
// Define configurations for each SSO provider
const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
google: {
envVarMap: {
google_client_id: "GOOGLE_CLIENT_ID",
google_client_secret: "GOOGLE_CLIENT_SECRET",
},
fields: [
{ label: "Google Client ID", name: "google_client_id" },
{ label: "Google Client Secret", name: "google_client_secret" },
],
},
microsoft: {
envVarMap: {
microsoft_client_id: "MICROSOFT_CLIENT_ID",
microsoft_client_secret: "MICROSOFT_CLIENT_SECRET",
microsoft_tenant: "MICROSOFT_TENANT",
},
fields: [
{ label: "Microsoft Client ID", name: "microsoft_client_id" },
{ label: "Microsoft Client Secret", name: "microsoft_client_secret" },
{ label: "Microsoft Tenant", name: "microsoft_tenant" },
],
},
okta: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{
label: "Authorization Endpoint",
name: "generic_authorization_endpoint",
placeholder: "https://your-domain/authorize",
},
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
{
label: "Userinfo Endpoint",
name: "generic_userinfo_endpoint",
placeholder: "https://your-domain/userinfo",
},
],
},
generic: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{ label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
{ label: "Token Endpoint", name: "generic_token_endpoint" },
{ label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
],
},
};
const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({ isVisible, onCancel, onSuccess, accessToken }) => {
const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync, isPending } = useEditSSOSettings();
// Enhanced form submission handler
const handleFormSubmit = async (formValues: Record<string, any>) => {
if (!accessToken) {
NotificationsManager.fromBackend("No access token available");
return;
}
const payload = processSSOSettingsPayload(formValues);
setIsSubmitting(true);
try {
// Save SSO settings using the new API
await updateSSOSettings(accessToken, formValues);
NotificationsManager.success("SSO settings added successfully");
// Reset form and close modal
form.resetFields();
onSuccess();
} catch (error: unknown) {
NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
// Helper function to render provider fields
const renderProviderFields = (provider: string) => {
const config = ssoProviderConfigs[provider];
if (!config) return null;
return config.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
>
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
</Form.Item>
));
await mutateAsync(payload, {
onSuccess: () => {
NotificationsManager.success("SSO settings added successfully");
onSuccess();
},
onError: (error) => {
NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
},
});
};
const handleCancel = () => {
@@ -148,91 +39,23 @@ const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({ isVisible, on
};
return (
<Modal title="Add SSO" visible={isVisible} width={800} footer={null} onCancel={handleCancel}>
<Form form={form} onFinish={handleFormSubmit} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<Form.Item
label="SSO Provider"
name="sso_provider"
rules={[{ required: true, message: "Please select an SSO provider" }]}
>
<Select>
{Object.entries(ssoProviderLogoMap).map(([value, logo]) => (
<Select.Option key={value} value={value}>
<div style={{ display: "flex", alignItems: "center", padding: "4px 0" }}>
{logo && (
<img
src={logo}
alt={value}
style={{ height: 24, width: 24, marginRight: 12, objectFit: "contain" }}
/>
)}
<span>
{value.toLowerCase() === "okta" ? "Okta / Auth0" : value.charAt(0).toUpperCase() + value.slice(1)}{" "}
SSO
</span>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.sso_provider !== currentValues.sso_provider}
>
{({ getFieldValue }) => {
const provider = getFieldValue("sso_provider");
return provider ? renderProviderFields(provider) : null;
}}
</Form.Item>
<Form.Item
label="Proxy Admin Email"
name="user_email"
rules={[{ required: true, message: "Please enter the email of the proxy admin" }]}
>
<TextInput />
</Form.Item>
<Form.Item
label="Proxy Base URL"
name="proxy_base_url"
normalize={(value) => value?.trim()}
rules={[
{ required: true, message: "Please enter the proxy base url" },
{
pattern: /^https?:\/\/.+/,
message: "URL must start with http:// or https://",
},
{
validator: (_, value) => {
// Only check for trailing slash if the URL starts with http:// or https://
if (value && /^https?:\/\/.+/.test(value) && value.endsWith("/")) {
return Promise.reject("URL must not end with a trailing slash");
}
return Promise.resolve();
},
},
]}
>
<TextInput placeholder="https://example.com" />
</Form.Item>
<div
style={{
textAlign: "right",
marginTop: "10px",
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
gap: "8px",
}}
>
<Button2 onClick={handleCancel}>Cancel</Button2>
<Button2 htmlType="submit" loading={isSubmitting}>
Add SSO
</Button2>
</div>
</Form>
<Modal
title="Add SSO"
open={isVisible}
width={800}
footer={
<Space>
<Button onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button loading={isPending} onClick={() => form.submit()}>
{isPending ? "Adding..." : "Add SSO"}
</Button>
</Space>
}
onCancel={handleCancel}
>
<BaseSSOSettingsForm form={form} onFormSubmit={handleFormSubmit} />
</Modal>
);
};
@@ -0,0 +1,249 @@
"use client";
import { TextInput } from "@tremor/react";
import { Checkbox, Form, Input, Select } from "antd";
import React from "react";
import { ssoProviderLogoMap, ssoProviderDisplayNames } from "../constants";
export interface BaseSSOSettingsFormProps {
form: any; // Replace with proper Form type if available
onFormSubmit: (formValues: Record<string, any>) => Promise<void>;
}
// Define the SSO provider configuration type
export interface SSOProviderConfig {
envVarMap: Record<string, string>;
fields: Array<{
label: string;
name: string;
placeholder?: string;
}>;
}
// Define configurations for each SSO provider
export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
google: {
envVarMap: {
google_client_id: "GOOGLE_CLIENT_ID",
google_client_secret: "GOOGLE_CLIENT_SECRET",
},
fields: [
{ label: "Google Client ID", name: "google_client_id" },
{ label: "Google Client Secret", name: "google_client_secret" },
],
},
microsoft: {
envVarMap: {
microsoft_client_id: "MICROSOFT_CLIENT_ID",
microsoft_client_secret: "MICROSOFT_CLIENT_SECRET",
microsoft_tenant: "MICROSOFT_TENANT",
},
fields: [
{ label: "Microsoft Client ID", name: "microsoft_client_id" },
{ label: "Microsoft Client Secret", name: "microsoft_client_secret" },
{ label: "Microsoft Tenant", name: "microsoft_tenant" },
],
},
okta: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{
label: "Authorization Endpoint",
name: "generic_authorization_endpoint",
placeholder: "https://your-domain/authorize",
},
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
{
label: "Userinfo Endpoint",
name: "generic_userinfo_endpoint",
placeholder: "https://your-domain/userinfo",
},
],
},
generic: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{ label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
{ label: "Token Endpoint", name: "generic_token_endpoint" },
{ label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
],
},
};
// Helper function to render provider fields
export const renderProviderFields = (provider: string) => {
const config = ssoProviderConfigs[provider];
if (!config) return null;
return config.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
>
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
</Form.Item>
));
};
const BaseSSOSettingsForm: React.FC<BaseSSOSettingsFormProps> = ({ form, onFormSubmit }) => {
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 style={{ display: "flex", alignItems: "center", padding: "4px 0" }}>
{logo && (
<img
src={logo}
alt={value}
style={{ height: 24, width: 24, marginRight: 12, objectFit: "contain" }}
/>
)}
<span>
{ssoProviderDisplayNames[value] || value.charAt(0).toUpperCase() + value.slice(1) + " SSO"}
</span>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.sso_provider !== currentValues.sso_provider}
>
{({ getFieldValue }) => {
const provider = getFieldValue("sso_provider");
return provider ? renderProviderFields(provider) : null;
}}
</Form.Item>
<Form.Item
label="Proxy Admin Email"
name="user_email"
rules={[{ required: true, message: "Please enter the email of the proxy admin" }]}
>
<TextInput />
</Form.Item>
<Form.Item
label="Proxy Base URL"
name="proxy_base_url"
normalize={(value) => value?.trim()}
rules={[
{ required: true, message: "Please enter the proxy base url" },
{
pattern: /^https?:\/\/.+/,
message: "URL must start with http:// or https://",
},
{
validator: (_, value) => {
// Only check for trailing slash if the URL starts with http:// or https://
if (value && /^https?:\/\/.+/.test(value) && value.endsWith("/")) {
return Promise.reject("URL must not end with a trailing slash");
}
return Promise.resolve();
},
},
]}
>
<TextInput placeholder="https://example.com" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.sso_provider !== currentValues.sso_provider}
>
{({ getFieldValue }) => {
const provider = getFieldValue("sso_provider");
return provider === "okta" || provider === "generic" ? (
<Form.Item label="Use Role Mappings" name="use_role_mappings" valuePropName="checked">
<Checkbox />
</Form.Item>
) : null;
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.use_role_mappings !== currentValues.use_role_mappings}
>
{({ getFieldValue }) => {
const useRoleMappings = getFieldValue("use_role_mappings");
return useRoleMappings ? (
<Form.Item
label="Group Claim"
name="group_claim"
rules={[{ required: true, message: "Please enter the group claim" }]}
>
<TextInput />
</Form.Item>
) : null;
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.use_role_mappings !== currentValues.use_role_mappings}
>
{({ getFieldValue }) => {
const useRoleMappings = getFieldValue("use_role_mappings");
return useRoleMappings ? (
<>
<Form.Item label="Default Role" name="default_role" initialValue="Internal User">
<Select>
<Select.Option value="internal_user_viewer">Internal Viewer</Select.Option>
<Select.Option value="internal_user">Internal User</Select.Option>
<Select.Option value="proxy_admin_viewer">Admin Viewer</Select.Option>
<Select.Option value="proxy_admin">Proxy Admin</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Proxy Admin Teams" name="proxy_admin_teams">
<TextInput />
</Form.Item>
<Form.Item label="Admin Viewer Teams" name="admin_viewer_teams">
<TextInput />
</Form.Item>
<Form.Item label="Internal User Teams" name="internal_user_teams">
<TextInput />
</Form.Item>
<Form.Item label="Internal Viewer Teams" name="internal_viewer_teams">
<TextInput />
</Form.Item>
</>
) : null;
}}
</Form.Item>
</Form>
</div>
);
};
export default BaseSSOSettingsForm;
@@ -0,0 +1,131 @@
"use client";
import { Button, Form, Modal, Space } 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";
interface EditSSOSettingsModalProps {
isVisible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
const [form] = Form.useForm();
// 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;
console.log("Raw SSO data received:", ssoData); // Debug log
console.log("SSO values:", ssoData.values); // Debug log
console.log("user_email from API:", ssoData.values.user_email); // Debug log
// Determine which SSO provider is configured
let selectedProvider = null;
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")
) {
selectedProvider = "okta";
} else {
selectedProvider = "generic";
}
}
// Extract role mappings if they exist
let roleMappingFields = {};
if (ssoData.values.role_mappings) {
const roleMappings = ssoData.values.role_mappings;
// Helper function to join arrays into comma-separated strings
const joinTeams = (teams: string[] | undefined): string => {
if (!teams || teams.length === 0) return "";
return teams.join(", ");
};
roleMappingFields = {
use_role_mappings: true,
group_claim: roleMappings.group_claim,
default_role: roleMappings.default_role || "internal_user",
proxy_admin_teams: joinTeams(roleMappings.roles?.proxy_admin),
admin_viewer_teams: joinTeams(roleMappings.roles?.proxy_admin_viewer),
internal_user_teams: joinTeams(roleMappings.roles?.internal_user),
internal_viewer_teams: joinTeams(roleMappings.roles?.internal_user_viewer),
};
}
// Set form values with existing data (excluding UI access control fields)
const formValues = {
sso_provider: selectedProvider,
...ssoData.values,
...roleMappingFields,
};
console.log("Setting form values:", formValues); // Debug log
// Clear form first, then set values with a small delay to ensure proper initialization
form.resetFields();
setTimeout(() => {
form.setFieldsValue(formValues);
console.log("Form values set, current form values:", form.getFieldsValue()); // Debug log
}, 100);
}
}, [isVisible, ssoSettings.data, form]);
// Enhanced form submission handler
const handleFormSubmit = async (formValues: Record<string, any>) => {
const payload = processSSOSettingsPayload(formValues);
await mutateAsync(payload, {
onSuccess: () => {
NotificationsManager.success("SSO settings updated successfully");
onSuccess();
},
onError: (error) => {
NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
},
});
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
return (
<Modal
title="Edit SSO Settings"
open={isVisible}
width={800}
footer={
<Space>
<Button onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button loading={isPending} onClick={() => form.submit()}>
{isPending ? "Saving..." : "Save"}
</Button>
</Space>
}
onCancel={handleCancel}
>
<BaseSSOSettingsForm form={form} onFormSubmit={handleFormSubmit} />
</Modal>
);
};
export default EditSSOSettingsModal;
@@ -0,0 +1,38 @@
import { useState } from "react";
import { Button } from "antd";
import { Eye, EyeOff } from "lucide-react";
export default function RedactableField({
defaultHidden = true,
value,
}: {
defaultHidden?: boolean;
value: string | null;
}) {
const [isHidden, setIsHidden] = useState(defaultHidden);
return (
<div className="flex items-center gap-2">
<span className="font-mono text-gray-600 flex-1">
{value ? (
isHidden ? (
"•".repeat(value.length)
) : (
value
)
) : (
<span className="text-gray-400 italic">Not configured</span>
)}
</span>
{value && (
<Button
type="text"
size="small"
icon={isHidden ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
onClick={() => setIsHidden(!isHidden)}
className="text-gray-400 hover:text-gray-600"
/>
)}
</div>
);
}
@@ -3,11 +3,14 @@
import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Badge, Button, Card, Descriptions, Space, Typography } from "antd";
import { Shield, Trash2 } from "lucide-react";
import { Shield, Trash2, Edit } from "lucide-react";
import { useState } from "react";
import AddSSOSettingsModal from "./Modals/AddSSOSettingsModal";
import DeleteSSOSettingsModal from "./Modals/DeleteSSOSettingsModal";
import EditSSOSettingsModal from "./Modals/EditSSOSettingsModal";
import SSOSettingsEmptyPlaceholder from "./SSOSettingsEmptyPlaceholder";
import RedactableField from "./RedactableField";
import { ssoProviderLogoMap, ssoProviderDisplayNames } from "./constants";
const { Title, Text } = Typography;
@@ -16,6 +19,7 @@ export default function SSOSettings() {
const { accessToken } = useAuthorized();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const isSSOConfigured =
Boolean(ssoSettings?.values.google_client_id) ||
Boolean(ssoSettings?.values.microsoft_client_id) ||
@@ -39,12 +43,6 @@ export default function SSOSettings() {
}
}
const renderRedactedValue = (value?: string | null) => (
<span className="font-mono text-gray-600">
{value ? "••••••••••••••••••••••••••••••••" : <span className="text-gray-400 italic">Not configured</span>}
</span>
);
const renderEndpointValue = (value?: string | null) => (
<span className="font-mono text-gray-600 text-sm break-all">
{value || <span className="text-gray-400 italic">Not configured</span>}
@@ -67,44 +65,44 @@ export default function SSOSettings() {
const providerConfigs = {
google: {
providerText: "Google OAuth",
providerText: ssoProviderDisplayNames.google,
fields: [
{
label: "Client ID (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.google_client_id),
label: "Client ID",
render: (values: SSOSettingsValues) => <RedactableField value={values.google_client_id} />,
},
{
label: "Client Secret (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.google_client_secret),
label: "Client Secret",
render: (values: SSOSettingsValues) => <RedactableField value={values.google_client_secret} />,
},
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
],
},
microsoft: {
providerText: "Microsoft OAuth",
providerText: ssoProviderDisplayNames.microsoft,
fields: [
{
label: "Client ID (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.microsoft_client_id),
label: "Client ID",
render: (values: SSOSettingsValues) => <RedactableField value={values.microsoft_client_id} />,
},
{
label: "Client Secret (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.microsoft_client_secret),
label: "Client Secret",
render: (values: SSOSettingsValues) => <RedactableField value={values.microsoft_client_secret} />,
},
{ label: "Tenant", render: (values: any) => renderSimpleValue(values.microsoft_tenant) },
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
],
},
okta: {
providerText: "Okta/Auth0",
providerText: ssoProviderDisplayNames.okta,
fields: [
{
label: "Client ID (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.generic_client_id),
label: "Client ID",
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_id} />,
},
{
label: "Client Secret (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.generic_client_secret),
label: "Client Secret",
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_secret} />,
},
{
label: "Authorization Endpoint",
@@ -122,15 +120,15 @@ export default function SSOSettings() {
],
},
generic: {
providerText: "Generic OAuth",
providerText: ssoProviderDisplayNames.generic,
fields: [
{
label: "Client ID (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.generic_client_id),
label: "Client ID",
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_id} />,
},
{
label: "Client Secret (Redacted)",
render: (values: SSOSettingsValues) => renderRedactedValue(values.generic_client_secret),
label: "Client Secret",
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_secret} />,
},
{
label: "Authorization Endpoint",
@@ -160,7 +158,16 @@ export default function SSOSettings() {
return (
<Descriptions bordered {...descriptionsConfig}>
<Descriptions.Item label="Provider">
<Badge status="success" text={config.providerText} />
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{ssoProviderLogoMap[selectedProvider] && (
<img
src={ssoProviderLogoMap[selectedProvider]}
alt={selectedProvider}
style={{ height: 24, width: 24, objectFit: "contain" }}
/>
)}
<span>{config.providerText}</span>
</div>
</Descriptions.Item>
{config.fields.map((field, index) => (
<Descriptions.Item key={index} label={field.label}>
@@ -186,9 +193,14 @@ export default function SSOSettings() {
<div className="flex items-center gap-3">
{isSSOConfigured && (
<Button danger icon={<Trash2 className="w-4 h-4" />} onClick={() => setIsDeleteModalVisible(true)}>
Delete SSO Settings
</Button>
<>
<Button icon={<Edit className="w-4 h-4" />} onClick={() => setIsEditModalVisible(true)}>
Edit SSO Settings
</Button>
<Button danger icon={<Trash2 className="w-4 h-4" />} onClick={() => setIsDeleteModalVisible(true)}>
Delete SSO Settings
</Button>
</>
)}
</div>
</div>
@@ -214,7 +226,15 @@ export default function SSOSettings() {
setIsAddModalVisible(false);
refetch();
}}
accessToken={accessToken}
/>
<EditSSOSettingsModal
isVisible={isEditModalVisible}
onCancel={() => setIsEditModalVisible(false)}
onSuccess={() => {
setIsEditModalVisible(false);
refetch();
}}
/>
</Card>
);
@@ -0,0 +1,15 @@
// SSO Provider logos
export const ssoProviderLogoMap: Record<string, string> = {
google: "https://artificialanalysis.ai/img/logos/google_small.svg",
microsoft: "https://upload.wikimedia.org/wikipedia/commons/a/a8/Microsoft_Azure_Logo.svg",
okta: "https://www.okta.com/sites/default/files/Okta_Logo_BrightBlue_Medium.png",
generic: "",
};
// SSO Provider display names (consistent between select dropdown and table)
export const ssoProviderDisplayNames: Record<string, string> = {
google: "Google SSO",
microsoft: "Microsoft SSO",
okta: "Okta / Auth0 SSO",
generic: "Generic SSO",
};
@@ -0,0 +1,274 @@
import { processSSOSettingsPayload } from "./utils";
import { describe, it, expect } from "vitest";
describe("processSSOSettingsPayload", () => {
describe("without role mappings", () => {
it("should return all fields except role mapping fields when use_role_mappings is false", () => {
const formValues = {
proxy_admin_teams: "team1, team2",
admin_viewer_teams: "viewer1",
internal_user_teams: "user1",
internal_viewer_teams: "viewer1",
default_role: "proxy_admin",
group_claim: "groups",
use_role_mappings: false,
other_field: "value",
another_field: 123,
};
const result = processSSOSettingsPayload(formValues);
expect(result).toEqual({
other_field: "value",
another_field: 123,
});
expect(result.role_mappings).toBeUndefined();
});
it("should return all fields except role mapping fields when use_role_mappings is not present", () => {
const formValues = {
proxy_admin_teams: "team1",
admin_viewer_teams: "viewer1",
internal_user_teams: "user1",
internal_viewer_teams: "viewer1",
default_role: "proxy_admin",
group_claim: "groups",
other_field: "value",
};
const result = processSSOSettingsPayload(formValues);
expect(result).toEqual({
other_field: "value",
});
expect(result.role_mappings).toBeUndefined();
});
});
describe("with role mappings enabled", () => {
it("should create role mappings with all team types populated", () => {
const formValues = {
proxy_admin_teams: "admin1, admin2",
admin_viewer_teams: "viewer1, viewer2, viewer3",
internal_user_teams: "user1",
internal_viewer_teams: "internal_viewer1, internal_viewer2",
default_role: "proxy_admin",
group_claim: "groups",
use_role_mappings: true,
other_field: "value",
};
const result = processSSOSettingsPayload(formValues);
expect(result.other_field).toBe("value");
expect(result.role_mappings).toEqual({
provider: "generic",
group_claim: "groups",
default_role: "proxy_admin",
roles: {
proxy_admin: ["admin1", "admin2"],
proxy_admin_viewer: ["viewer1", "viewer2", "viewer3"],
internal_user: ["user1"],
internal_user_viewer: ["internal_viewer1", "internal_viewer2"],
},
});
});
it("should handle empty team strings", () => {
const formValues = {
proxy_admin_teams: "",
admin_viewer_teams: "",
internal_user_teams: "",
internal_viewer_teams: "",
default_role: "internal_user",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.roles).toEqual({
proxy_admin: [],
proxy_admin_viewer: [],
internal_user: [],
internal_user_viewer: [],
});
});
it("should handle undefined team fields", () => {
const formValues = {
default_role: "internal_user_viewer",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.roles).toEqual({
proxy_admin: [],
proxy_admin_viewer: [],
internal_user: [],
internal_user_viewer: [],
});
});
it("should handle whitespace-only team strings", () => {
const formValues = {
proxy_admin_teams: " ",
admin_viewer_teams: ", , ,",
internal_user_teams: "user1, , user2",
internal_viewer_teams: "viewer1, ,viewer2",
default_role: "proxy_admin_viewer",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.roles).toEqual({
proxy_admin: [],
proxy_admin_viewer: [],
internal_user: ["user1", "user2"],
internal_user_viewer: ["viewer1", "viewer2"],
});
});
it("should trim whitespace from team names", () => {
const formValues = {
proxy_admin_teams: " admin1 , admin2 ",
admin_viewer_teams: " viewer1 ",
internal_user_teams: " user1 , user2 ",
internal_viewer_teams: "viewer1,viewer2",
default_role: "internal_user",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.roles).toEqual({
proxy_admin: ["admin1", "admin2"],
proxy_admin_viewer: ["viewer1"],
internal_user: ["user1", "user2"],
internal_user_viewer: ["viewer1", "viewer2"],
});
});
it("should filter out empty strings after trimming", () => {
const formValues = {
proxy_admin_teams: "admin1,,admin2, , admin3",
default_role: "internal_user",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.roles.proxy_admin).toEqual(["admin1", "admin2", "admin3"]);
});
});
describe("default role mapping", () => {
it("should map internal_user_viewer correctly", () => {
const formValues = {
default_role: "internal_user_viewer",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.default_role).toBe("internal_user_viewer");
});
it("should map internal_user correctly", () => {
const formValues = {
default_role: "internal_user",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.default_role).toBe("internal_user");
});
it("should map proxy_admin_viewer correctly", () => {
const formValues = {
default_role: "proxy_admin_viewer",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.default_role).toBe("proxy_admin_viewer");
});
it("should map proxy_admin correctly", () => {
const formValues = {
default_role: "proxy_admin",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.default_role).toBe("proxy_admin");
});
it("should default to internal_user for unknown roles", () => {
const formValues = {
default_role: "unknown_role",
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.default_role).toBe("internal_user");
});
it("should default to internal_user for undefined default_role", () => {
const formValues = {
group_claim: "groups",
use_role_mappings: true,
};
const result = processSSOSettingsPayload(formValues);
expect(result.role_mappings.default_role).toBe("internal_user");
});
});
describe("edge cases", () => {
it("should handle empty form values", () => {
const result = processSSOSettingsPayload({});
expect(result).toEqual({});
});
it("should preserve other fields in the payload", () => {
const formValues = {
use_role_mappings: false,
sso_provider: "google",
client_id: "123",
client_secret: "secret",
redirect_url: "http://example.com",
custom_field: { nested: "value" },
array_field: [1, 2, 3],
};
const result = processSSOSettingsPayload(formValues);
expect(result).toEqual({
sso_provider: "google",
client_id: "123",
client_secret: "secret",
redirect_url: "http://example.com",
custom_field: { nested: "value" },
array_field: [1, 2, 3],
});
});
});
});
@@ -0,0 +1,54 @@
/**
* Processes SSO settings form values and transforms them into the payload format expected by the API
* Handles role mappings transformation and field extraction
*/
export const processSSOSettingsPayload = (formValues: Record<string, any>): Record<string, any> => {
const {
proxy_admin_teams,
admin_viewer_teams,
internal_user_teams,
internal_viewer_teams,
default_role,
group_claim,
use_role_mappings,
...rest
} = formValues;
const payload: any = {
...rest,
};
// Add role mappings if use_role_mappings is checked
if (use_role_mappings) {
// Helper function to split comma-separated string into array
const splitTeams = (teams: string | undefined): string[] => {
if (!teams || teams.trim() === "") return [];
return teams
.split(",")
.map((team) => team.trim())
.filter((team) => team.length > 0);
};
// Map default role display values to backend values
const defaultRoleMapping: Record<string, string> = {
internal_user_viewer: "internal_user_viewer",
internal_user: "internal_user",
proxy_admin_viewer: "proxy_admin_viewer",
proxy_admin: "proxy_admin",
};
payload.role_mappings = {
provider: "generic",
group_claim,
default_role: defaultRoleMapping[default_role] || "internal_user",
roles: {
proxy_admin: splitTeams(proxy_admin_teams),
proxy_admin_viewer: splitTeams(admin_viewer_teams),
internal_user: splitTeams(internal_user_teams),
internal_user_viewer: splitTeams(internal_viewer_teams),
},
};
}
return payload;
};