Merge pull request #18677 from BerriAI/litellm_ui_sso_settings_role

[Feature] UI - SSO Settings Page Add Role Mappings
This commit is contained in:
yuneng-jiang
2026-01-05 17:40:29 -08:00
committed by GitHub
5 changed files with 224 additions and 40 deletions
@@ -1,7 +1,7 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { getSSOSettings } from "@/components/networking";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { getSSOSettings } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export interface SSOFieldSchema {
description: string;
@@ -27,13 +27,15 @@ export interface SSOSettingsValues {
proxy_base_url: string | null;
user_email: string | null;
ui_access_mode: 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[];
};
role_mappings: RoleMappings;
}
export interface RoleMappings {
provider: string;
group_claim: string;
default_role: "internal_user" | "internal_user_viewer" | "proxy_admin" | "proxy_admin_viewer";
roles: {
[key: string]: string[];
};
}
@@ -0,0 +1,92 @@
import type { RoleMappings as RoleMappingsType } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
import RoleMappings from "./RoleMappings";
describe("RoleMappings", () => {
it("should render successfully", () => {
const roleMappings: RoleMappingsType = {
provider: "generic",
group_claim: "groups",
default_role: "internal_user",
roles: {
proxy_admin: ["admin-group"],
proxy_admin_viewer: [],
internal_user: ["user-group"],
internal_user_viewer: [],
},
};
renderWithProviders(<RoleMappings roleMappings={roleMappings} />);
expect(screen.getByText("Role Mappings")).toBeInTheDocument();
});
it("should return null when roleMappings is undefined", () => {
const { container } = renderWithProviders(<RoleMappings roleMappings={undefined} />);
expect(container.firstChild).toBeNull();
});
it("should display Group Claim and Default Role with correct values and display names", () => {
const testCases: Array<{ role: RoleMappingsType["default_role"]; displayName: string; groupClaim: string }> = [
{ role: "internal_user_viewer", displayName: "Internal Viewer", groupClaim: "custom-groups-1" },
{ role: "internal_user", displayName: "Internal User", groupClaim: "custom-groups-2" },
{ role: "proxy_admin_viewer", displayName: "Proxy Admin Viewer", groupClaim: "custom-groups-3" },
{ role: "proxy_admin", displayName: "Proxy Admin", groupClaim: "custom-groups-4" },
];
testCases.forEach(({ role, displayName, groupClaim }) => {
const roleMappings: RoleMappingsType = {
provider: "generic",
group_claim: groupClaim,
default_role: role,
roles: {
proxy_admin: [],
proxy_admin_viewer: [],
internal_user: [],
internal_user_viewer: [],
},
};
const { unmount } = renderWithProviders(<RoleMappings roleMappings={roleMappings} />);
expect(screen.getByText("Group Claim")).toBeInTheDocument();
expect(screen.getByText(groupClaim)).toBeInTheDocument();
expect(screen.getByText("Default Role")).toBeInTheDocument();
const displayNameElements = screen.getAllByText(displayName);
expect(displayNameElements.length).toBeGreaterThan(0);
unmount();
});
});
it("should display table with roles, groups as Tags when mapped, and 'No groups mapped' when empty", () => {
const roleMappings: RoleMappingsType = {
provider: "generic",
group_claim: "groups",
default_role: "internal_user",
roles: {
proxy_admin: ["admin-group-1", "admin-group-2", "admin-group-3"],
proxy_admin_viewer: ["viewer-group"],
internal_user: ["user-group"],
internal_user_viewer: [],
},
};
renderWithProviders(<RoleMappings roleMappings={roleMappings} />);
expect(screen.getByText("Role")).toBeInTheDocument();
expect(screen.getByText("Mapped Groups")).toBeInTheDocument();
expect(screen.getAllByText("Proxy Admin").length).toBeGreaterThan(0);
expect(screen.getAllByText("Proxy Admin Viewer").length).toBeGreaterThan(0);
expect(screen.getAllByText("Internal User").length).toBeGreaterThan(0);
expect(screen.getAllByText("Internal Viewer").length).toBeGreaterThan(0);
expect(screen.getByText("admin-group-1")).toBeInTheDocument();
expect(screen.getByText("admin-group-2")).toBeInTheDocument();
expect(screen.getByText("admin-group-3")).toBeInTheDocument();
expect(screen.getByText("viewer-group")).toBeInTheDocument();
expect(screen.getByText("user-group")).toBeInTheDocument();
expect(screen.getByText("No groups mapped")).toBeInTheDocument();
});
});
@@ -0,0 +1,74 @@
import type { RoleMappings as RoleMappingsType } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { Card, Divider, Table, Tag, Typography } from "antd";
import { Users } from "lucide-react";
import { defaultRoleDisplayNames } from "./constants";
const { Title, Text } = Typography;
export default function RoleMappings({ roleMappings }: { roleMappings: RoleMappingsType | undefined }) {
if (!roleMappings) {
return null;
}
const roleMappingsColumns = [
{
title: "Role",
dataIndex: "role",
key: "role",
render: (text: string) => <Text strong>{defaultRoleDisplayNames[text]}</Text>,
},
{
title: "Mapped Groups",
dataIndex: "groups",
key: "groups",
render: (groups: string[]) => (
<>
{groups.length > 0 ? (
groups.map((group, index) => (
<Tag key={index} color="blue">
{group}
</Tag>
))
) : (
<Text className="text-gray-400 italic">No groups mapped</Text>
)}
</>
),
},
];
return (
<Card>
<div className="flex items-center gap-3">
<Users className="w-6 h-6 text-gray-400 mb-2" />
<Title level={3}>Role Mappings</Title>
</div>
<div className="space-y-8">
<div className="grid grid-cols-2 gap-4">
<div>
<Title level={5}>Group Claim</Title>
<div>
<Text code>{roleMappings.group_claim}</Text>
</div>
</div>
<div>
<Title level={5}>Default Role</Title>
<div>
<Text strong>{defaultRoleDisplayNames[roleMappings.default_role]}</Text>
</div>
</div>
</div>
<Divider />
<Table
columns={roleMappingsColumns}
dataSource={Object.entries(roleMappings.roles).map(([role, groups]) => ({
role,
groups,
}))}
pagination={false}
bordered
size="small"
className="w-full"
/>
</div>
</Card>
);
}
@@ -5,13 +5,14 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Button, Card, Descriptions, Space, Typography } from "antd";
import { Edit, Shield, Trash2 } from "lucide-react";
import { useState } from "react";
import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants";
import AddSSOSettingsModal from "./Modals/AddSSOSettingsModal";
import DeleteSSOSettingsModal from "./Modals/DeleteSSOSettingsModal";
import EditSSOSettingsModal from "./Modals/EditSSOSettingsModal";
import RedactableField from "./RedactableField";
import RoleMappings from "./RoleMappings";
import SSOSettingsEmptyPlaceholder from "./SSOSettingsEmptyPlaceholder";
import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton";
import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants";
const { Title, Text } = Typography;
@@ -44,6 +45,7 @@ export default function SSOSettings() {
};
const selectedProvider = ssoSettings?.values ? detectSSOProvider(ssoSettings.values) : null;
const isRoleMappingsEnabled = Boolean(ssoSettings?.values.role_mappings);
const renderEndpointValue = (value?: string | null) => (
<Text className="font-mono text-gray-600 text-sm" copyable={!!value}>
@@ -185,39 +187,46 @@ export default function SSOSettings() {
{isLoading ? (
<SSOSettingsLoadingSkeleton />
) : (
<Card>
<Space direction="vertical" size="large" className="w-full">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-gray-400" />
<div>
<Title level={3}>SSO Configuration</Title>
<Text type="secondary">Manage Single Sign-On authentication settings</Text>
<Space direction="vertical" size="large" className="w-full">
<Card>
<Space direction="vertical" size="large" className="w-full">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-gray-400" />
<div>
<Title level={3}>SSO Configuration</Title>
<Text type="secondary">Manage Single Sign-On authentication settings</Text>
</div>
</div>
<div className="flex items-center gap-3">
{isSSOConfigured && (
<>
<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>
<div className="flex items-center gap-3">
{isSSOConfigured && (
<>
<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>
{isSSOConfigured ? (
renderSSOSettings()
) : (
<SSOSettingsEmptyPlaceholder onAdd={() => setIsAddModalVisible(true)} />
)}
</Space>
</Card>
{isSSOConfigured ? (
renderSSOSettings()
) : (
<SSOSettingsEmptyPlaceholder onAdd={() => setIsAddModalVisible(true)} />
)}
</Space>
</Card>
{isRoleMappingsEnabled && <RoleMappings roleMappings={ssoSettings?.values.role_mappings} />}
</Space>
)}
<DeleteSSOSettingsModal
@@ -13,3 +13,10 @@ export const ssoProviderDisplayNames: Record<string, string> = {
okta: "Okta / Auth0 SSO",
generic: "Generic SSO",
};
export const defaultRoleDisplayNames: Record<string, string> = {
internal_user_viewer: "Internal Viewer",
internal_user: "Internal User",
proxy_admin_viewer: "Proxy Admin Viewer",
proxy_admin: "Proxy Admin",
};