diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts index f03f3977115..0431a8d39f7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts @@ -28,6 +28,7 @@ export interface SSOSettingsValues { user_email: string | null; ui_access_mode: string | null; role_mappings: RoleMappings; + team_mappings: TeamMappings; } export interface RoleMappings { @@ -39,6 +40,10 @@ export interface RoleMappings { }; } +export interface TeamMappings { + team_ids_jwt_field: string; +} + export interface SSOSettingsResponse { values: SSOSettingsValues; field_schema: SSOFieldSchema; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx index a885bffa710..c68e2716f5b 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx @@ -151,6 +151,117 @@ describe("BaseSSOSettingsForm", () => { expect(screen.getByText("Default Role")).toBeInTheDocument(); }); }); + + it("should show team mappings checkbox for okta provider", async () => { + const TestWrapper = () => { + const [form] = Form.useForm(); + const handleSubmit = vi.fn(); + + return ; + }; + + renderWithProviders(); + + const providerSelect = screen.getByLabelText("SSO Provider"); + await act(async () => { + fireEvent.mouseDown(providerSelect); + }); + + await waitFor(() => { + const oktaOption = screen.getByText(/okta/i); + fireEvent.click(oktaOption); + }); + + await waitFor(() => { + expect(screen.getByText("Use Team Mappings")).toBeInTheDocument(); + }); + }); + + it("should show team mappings checkbox for generic provider", async () => { + const TestWrapper = () => { + const [form] = Form.useForm(); + const handleSubmit = vi.fn(); + + return ; + }; + + renderWithProviders(); + + 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 waitFor(() => { + expect(screen.getByText("Use Team Mappings")).toBeInTheDocument(); + }); + }); + + 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 ; + }; + + renderWithProviders(); + + const providerSelect = screen.getByLabelText("SSO Provider"); + await act(async () => { + fireEvent.mouseDown(providerSelect); + }); + + await waitFor(() => { + const oktaOption = screen.getByText(/okta/i); + fireEvent.click(oktaOption); + }); + + await waitFor(() => { + expect(screen.getByText("Use Team Mappings")).toBeInTheDocument(); + }); + + const checkbox = screen.getByLabelText("Use Team Mappings"); + await act(async () => { + fireEvent.click(checkbox); + }); + + await waitFor(() => { + expect(screen.getByText("Team IDs JWT Field")).toBeInTheDocument(); + }); + }); + + it("should not show team mappings checkbox for google provider", async () => { + const TestWrapper = () => { + const [form] = Form.useForm(); + const handleSubmit = vi.fn(); + + return ; + }; + + renderWithProviders(); + + 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 waitFor(() => { + expect(screen.getByText("Google Client ID")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Use Team Mappings")).not.toBeInTheDocument(); + }); }); describe("renderProviderFields", () => { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx index 6431b2dd3ac..d16b04466e0 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx @@ -251,6 +251,43 @@ const BaseSSOSettingsForm: React.FC = ({ form, onFormS ) : null; }} + + prevValues.sso_provider !== currentValues.sso_provider} + > + {({ getFieldValue }) => { + const provider = getFieldValue("sso_provider"); + return provider === "okta" || provider === "generic" ? ( + + + + ) : null; + }} + + + + 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 ? ( + + + + ) : null; + }} + ); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx index 44cbf0020eb..2656c861aa8 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx @@ -33,6 +33,7 @@ const DeleteSSOSettingsModal: React.FC = ({ isVisib user_email: null, sso_provider: null, role_mappings: null, + team_mappings: null, }; await editSSOSettings(clearSettings, { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx index 559d837b409..d2d54033395 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx @@ -105,6 +105,14 @@ const createRoleMappingsSSOData = (overrides: Record = {}) => ...overrides, }); +const createTeamMappingsSSOData = (overrides: Record = {}) => + createGenericSSOData({ + team_mappings: { + team_ids_jwt_field: overrides.team_ids_jwt_field || "teams", + }, + ...overrides, + }); + // Mock utilities const createMockHooks = (): { useSSOSettings: SSOSettingsHookReturn; @@ -577,6 +585,104 @@ describe("EditSSOSettingsModal", () => { }); }); }); + }); + + describe("Team Mappings", () => { + it("processes team mappings when team_mappings exists", async () => { + const ssoData = createTeamMappingsSSOData(); + + setupMocks({ + useSSOSettings: { data: ssoData, isLoading: false, error: null }, + }); + + renderComponent(); + + await waitFor(() => { + expect(mockForm.setFieldsValue).toHaveBeenCalledWith({ + sso_provider: SSO_PROVIDERS.GENERIC, + ...ssoData.values, + use_team_mappings: true, + team_ids_jwt_field: "teams", + }); + }); + }); + + it("handles team mappings with custom JWT field name", async () => { + const ssoData = createTeamMappingsSSOData({ + team_ids_jwt_field: "custom_teams_field", + }); + + setupMocks({ + useSSOSettings: { data: ssoData, isLoading: false, error: null }, + }); + + renderComponent(); + + await waitFor(() => { + expect(mockForm.setFieldsValue).toHaveBeenCalledWith({ + sso_provider: SSO_PROVIDERS.GENERIC, + ...ssoData.values, + use_team_mappings: true, + team_ids_jwt_field: "custom_teams_field", + }); + }); + }); + + it("handles team mappings and role mappings together", async () => { + const ssoData = createGenericSSOData({ + role_mappings: { + group_claim: "groups", + default_role: "internal_user", + roles: { + proxy_admin: ["admin-group"], + proxy_admin_viewer: [], + internal_user: [], + internal_user_viewer: [], + }, + }, + team_mappings: { + team_ids_jwt_field: "teams", + }, + }); + + setupMocks({ + useSSOSettings: { data: ssoData, isLoading: false, error: null }, + }); + + renderComponent(); + + await waitFor(() => { + expect(mockForm.setFieldsValue).toHaveBeenCalledWith({ + sso_provider: SSO_PROVIDERS.GENERIC, + ...ssoData.values, + use_role_mappings: true, + group_claim: "groups", + default_role: "internal_user", + proxy_admin_teams: "admin-group", + admin_viewer_teams: "", + internal_user_teams: "", + internal_viewer_teams: "", + use_team_mappings: true, + team_ids_jwt_field: "teams", + }); + }); + }); + + it("does not set team mapping fields when team_mappings is not present", async () => { + const ssoData = createGenericSSOData(); + + setupMocks({ + useSSOSettings: { data: ssoData, isLoading: false, error: null }, + }); + + renderComponent(); + + await waitFor(() => { + const callArgs = mockForm.setFieldsValue.mock.calls[0][0]; + expect(callArgs.use_team_mappings).toBeUndefined(); + expect(callArgs.team_ids_jwt_field).toBeUndefined(); + }); + }); it("handles provider detection with partial SSO data", async () => { const ssoData = createSSOData({ diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx index a731af68ff1..bbae8f1451a 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx @@ -68,11 +68,22 @@ const EditSSOSettingsModal: React.FC = ({ isVisible, }; } + // Extract team mappings if they exist + let teamMappingFields = {}; + if (ssoData.values.team_mappings) { + const teamMappings = ssoData.values.team_mappings; + teamMappingFields = { + use_team_mappings: true, + team_ids_jwt_field: teamMappings.team_ids_jwt_field, + }; + } + // Set form values with existing data (excluding UI access control fields) const formValues = { sso_provider: selectedProvider, ...ssoData.values, ...roleMappingFields, + ...teamMappingFields, }; console.log("Setting form values:", formValues); // Debug log diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx index adc1251cde2..fdeda0ece3e 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx @@ -1,7 +1,7 @@ "use client"; import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; -import { Button, Card, Descriptions, Space, Typography } from "antd"; +import { Button, Card, Descriptions, Space, Tag, Typography } from "antd"; import { Edit, Shield, Trash2 } from "lucide-react"; import { useState } from "react"; import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants"; @@ -28,6 +28,7 @@ export default function SSOSettings() { const selectedProvider = ssoSettings?.values ? detectSSOProvider(ssoSettings.values) : null; const isRoleMappingsEnabled = Boolean(ssoSettings?.values.role_mappings); + const isTeamMappingsEnabled = Boolean(ssoSettings?.values.team_mappings); const renderEndpointValue = (value?: string | null) => ( @@ -38,6 +39,15 @@ export default function SSOSettings() { const renderSimpleValue = (value?: string | null) => value ? value : Not configured; + const renderTeamMappingsField = (values: SSOSettingsValues) => { + if (!values.team_mappings?.team_ids_jwt_field) { + return Not configured; + } + return ( + {values.team_mappings.team_ids_jwt_field} + ); + }; + const descriptionsConfig = { column: { xxl: 1, @@ -103,6 +113,10 @@ export default function SSOSettings() { render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint), }, { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) }, + isTeamMappingsEnabled ? { + label: "Team IDs JWT Field", + render: (values: SSOSettingsValues) => renderTeamMappingsField(values), + } : null, ], }, generic: { @@ -129,6 +143,10 @@ export default function SSOSettings() { render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint), }, { label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) }, + isTeamMappingsEnabled ? { + label: "Team IDs JWT Field", + render: (values: SSOSettingsValues) => renderTeamMappingsField(values), + } : null, ], }, }; @@ -155,7 +173,7 @@ export default function SSOSettings() { {config.providerText} - {config.fields.map((field, index) => ( + {config.fields.map((field, index) => field && ( {field.render(values)} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts index 718302d35fe..722d52d64f9 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts @@ -12,6 +12,8 @@ describe("processSSOSettingsPayload", () => { default_role: "proxy_admin", group_claim: "groups", use_role_mappings: false, + use_team_mappings: false, + team_ids_jwt_field: "teams", other_field: "value", another_field: 123, }; @@ -23,6 +25,7 @@ describe("processSSOSettingsPayload", () => { another_field: 123, }); expect(result.role_mappings).toBeUndefined(); + expect(result.team_mappings).toBeUndefined(); }); it("should return all fields except role mapping fields when use_role_mappings is not present", () => { @@ -33,6 +36,8 @@ describe("processSSOSettingsPayload", () => { internal_viewer_teams: "viewer1", default_role: "proxy_admin", group_claim: "groups", + use_team_mappings: false, + team_ids_jwt_field: "teams", other_field: "value", }; @@ -42,6 +47,7 @@ describe("processSSOSettingsPayload", () => { other_field: "value", }); expect(result.role_mappings).toBeUndefined(); + expect(result.team_mappings).toBeUndefined(); }); }); @@ -253,6 +259,143 @@ describe("processSSOSettingsPayload", () => { }); }); + describe("without team mappings", () => { + it("should return all fields except team mapping fields when use_team_mappings is false", () => { + const formValues = { + use_team_mappings: false, + team_ids_jwt_field: "teams", + sso_provider: "okta", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result).toEqual({ + sso_provider: "okta", + other_field: "value", + }); + expect(result.team_mappings).toBeUndefined(); + }); + + it("should return all fields except team mapping fields when use_team_mappings is not present", () => { + const formValues = { + team_ids_jwt_field: "teams", + sso_provider: "generic", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result).toEqual({ + sso_provider: "generic", + other_field: "value", + }); + expect(result.team_mappings).toBeUndefined(); + }); + + it("should not include team mappings for unsupported providers even when use_team_mappings is true", () => { + const formValues = { + use_team_mappings: true, + team_ids_jwt_field: "teams", + sso_provider: "google", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result).toEqual({ + sso_provider: "google", + other_field: "value", + }); + expect(result.team_mappings).toBeUndefined(); + }); + + it("should not include team mappings for microsoft provider even when use_team_mappings is true", () => { + const formValues = { + use_team_mappings: true, + team_ids_jwt_field: "teams", + sso_provider: "microsoft", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result).toEqual({ + sso_provider: "microsoft", + other_field: "value", + }); + expect(result.team_mappings).toBeUndefined(); + }); + }); + + describe("with team mappings enabled", () => { + it("should create team mappings for okta provider when use_team_mappings is true", () => { + const formValues = { + use_team_mappings: true, + team_ids_jwt_field: "teams", + sso_provider: "okta", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result.other_field).toBe("value"); + expect(result.team_mappings).toEqual({ + team_ids_jwt_field: "teams", + }); + }); + + it("should create team mappings for generic provider when use_team_mappings is true", () => { + const formValues = { + use_team_mappings: true, + team_ids_jwt_field: "custom_teams", + sso_provider: "generic", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result.other_field).toBe("value"); + expect(result.team_mappings).toEqual({ + team_ids_jwt_field: "custom_teams", + }); + }); + + it("should exclude team mapping fields from payload when team mappings are included", () => { + const formValues = { + use_team_mappings: true, + team_ids_jwt_field: "teams", + sso_provider: "okta", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result.use_team_mappings).toBeUndefined(); + expect(result.team_ids_jwt_field).toBeUndefined(); + }); + + it("should handle team mappings and role mappings together", () => { + const formValues = { + use_team_mappings: true, + team_ids_jwt_field: "teams", + use_role_mappings: true, + group_claim: "groups", + default_role: "internal_user", + sso_provider: "okta", + other_field: "value", + }; + + const result = processSSOSettingsPayload(formValues); + + expect(result.team_mappings).toEqual({ + team_ids_jwt_field: "teams", + }); + expect(result.role_mappings).toBeDefined(); + expect(result.role_mappings.group_claim).toBe("groups"); + }); + }); + describe("edge cases", () => { it("should handle empty form values", () => { const result = processSSOSettingsPayload({}); @@ -263,6 +406,7 @@ describe("processSSOSettingsPayload", () => { it("should preserve other fields in the payload", () => { const formValues = { use_role_mappings: false, + use_team_mappings: false, sso_provider: "google", client_id: "123", client_secret: "secret", diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts index c199048df3e..948ed4d2bfe 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts @@ -13,6 +13,8 @@ export const processSSOSettingsPayload = (formValues: Record): Reco default_role, group_claim, use_role_mappings, + use_team_mappings, + team_ids_jwt_field, ...rest } = formValues; @@ -21,7 +23,9 @@ export const processSSOSettingsPayload = (formValues: Record): Reco }; // Add role mappings only if use_role_mappings is checked AND provider supports role mappings - if (use_role_mappings) { + const provider = rest.sso_provider; + const supportsRoleMappings = provider === "okta" || provider === "generic"; + if (use_role_mappings && supportsRoleMappings) { // Helper function to split comma-separated string into array const splitTeams = (teams: string | undefined): string[] => { if (!teams || teams.trim() === "") return []; @@ -52,6 +56,14 @@ export const processSSOSettingsPayload = (formValues: Record): Reco }; } + // Add team mappings only if use_team_mappings is checked AND provider supports team mappings + const supportsTeamMappings = provider === "okta" || provider === "generic"; + if (use_team_mappings && supportsTeamMappings) { + payload.team_mappings = { + team_ids_jwt_field: team_ids_jwt_field, + }; + } + return payload; };