refactor(ui): migrate admin-panel to shadcn (#36635)

* test(ui): characterize admin settings components

* refactor(ui): migrate admin-panel to shadcn

* fix(ui): restore compatible page grouping

* test(ui): cover legacy page grouping runtimes

* test(ui): restore admin settings rendering contracts
This commit is contained in:
yuneng-jiang 2026-08-12 12:40:49 -07:00 committed by GitHub
parent 7d12f21e31
commit 4445eb71f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 669 additions and 927 deletions

View file

@ -2239,14 +2239,6 @@
"src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx": {
@ -2298,9 +2290,6 @@
"src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx": {
@ -2308,40 +2297,14 @@
"count": 1
}
},
"src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx": {
"max-nested-callbacks": {
"count": 4
}
},
"src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-render": {
"count": 2
"count": 1
}
},
"src/components/Settings/AdminSettings/UISettings/UISettings.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": {

View file

@ -0,0 +1,74 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
import HashicorpVault from "./HashicorpVault";
const mockUseAuthorized = vi.hoisted(() => vi.fn());
const mockUseHashicorpVaultConfig = vi.hoisted(() => vi.fn());
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: mockUseAuthorized,
}));
vi.mock("@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig", () => ({
useHashicorpVaultConfig: mockUseHashicorpVaultConfig,
}));
vi.mock("@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig", () => ({
useDeleteHashicorpVaultConfig: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig", () => ({
useUpdateHashicorpVaultConfig: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("./EditHashicorpVaultModal", () => ({
default: ({ isVisible }: { isVisible: boolean }) => (isVisible ? <div>Edit Vault Configuration</div> : null),
}));
vi.mock("@/components/common_components/DeleteResourceModal", () => ({
default: () => null,
}));
describe("HashicorpVault", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
mockUseHashicorpVaultConfig.mockReturnValue({
data: { values: {} },
isLoading: false,
isError: false,
error: null,
});
});
it("should render", () => {
renderWithProviders(<HashicorpVault />);
expect(screen.getByRole("heading", { name: "Hashicorp Vault" })).toBeInTheDocument();
});
it("should open the configuration editor from the empty state", async () => {
const user = userEvent.setup();
renderWithProviders(<HashicorpVault />);
await user.click(screen.getByRole("button", { name: /configure vault/i }));
expect(screen.getByText("Edit Vault Configuration")).toBeInTheDocument();
});
it("should display configured values and management actions", () => {
mockUseHashicorpVaultConfig.mockReturnValue({
data: { values: { vault_addr: "https://vault.example.com", vault_token: "secret" } },
isLoading: false,
isError: false,
error: null,
});
renderWithProviders(<HashicorpVault />);
expect(screen.getByText("https://vault.example.com")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /test connection/i })).toBeInTheDocument();
});
});

View file

@ -1,43 +1,49 @@
"use client";
import { Edit, ExternalLink, Info, KeyRound, PlugZap, Trash2 } from "lucide-react";
import { useState } from "react";
import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig";
import { testHashicorpVaultConnection } from "@/app/(dashboard)/hooks/configOverrides/hashicorpVaultApi";
import { useDeleteHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig";
import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig";
import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationManager from "@/components/molecules/notifications_manager";
import { testHashicorpVaultConnection } from "@/app/(dashboard)/hooks/configOverrides/hashicorpVaultApi";
import { Alert, Button, Card, Descriptions, Flex, Skeleton, Space, Typography } from "antd";
import { Edit, KeyRound, PlugZap, Trash2 } from "lucide-react";
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import EditHashicorpVaultModal from "./EditHashicorpVaultModal";
import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder";
import { FIELD_LABELS, SENSITIVE_FIELDS } from "./constants";
const { Title, Text } = Typography;
function detectAuthMethod(values: Record<string, any>): string {
function detectAuthMethod(values: Record<string, unknown>): string {
if (values.approle_role_id || values.approle_secret_id) return "AppRole";
if (values.client_cert && values.client_key) return "TLS Certificate";
if (values.vault_token) return "Token";
return "None";
}
const descriptionsConfig = {
column: { xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
};
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3">
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium text-foreground">{label}</dt>
<dd className="px-4 py-3 text-sm text-foreground sm:col-span-2">{children}</dd>
</div>
);
}
export default function HashicorpVault() {
const { accessToken } = useAuthorized();
const { data, isLoading, isError, error } = useHashicorpVaultConfig();
const { mutate: deleteConfig, isPending: isDeleting } = useDeleteHashicorpVaultConfig(accessToken);
const { mutate: updateConfig, isPending: isClearingField } = useUpdateHashicorpVaultConfig(accessToken);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [clearingField, setClearingField] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const rawValues = data?.values ?? {};
const isConfigured = Boolean(rawValues.vault_addr);
@ -60,9 +66,7 @@ export default function HashicorpVault() {
NotificationManager.success("Hashicorp Vault configuration deleted");
setIsDeleteModalOpen(false);
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
onError: (err) => NotificationManager.fromBackend(err),
});
};
@ -75,127 +79,116 @@ export default function HashicorpVault() {
NotificationManager.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`);
setClearingField(null);
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
onError: (err) => NotificationManager.fromBackend(err),
},
);
};
const renderValue = (key: string) => {
const value = rawValues[key];
if (!value) {
return <span className="text-gray-400 italic">Not configured</span>;
}
if (SENSITIVE_FIELDS.has(key)) {
return (
<Flex justify="space-between" align="center">
<Text className="font-mono text-gray-600">{value}</Text>
<Button
type="text"
size="small"
danger
icon={<Trash2 className="w-3.5 h-3.5" />}
onClick={() => setClearingField(key)}
/>
</Flex>
);
}
return <Text className="font-mono text-gray-600">{value}</Text>;
};
const renderSettings = () => {
// Only show fields that have values, plus auth method
const fieldsToShow = Object.entries(rawValues).filter(([_, value]) => value != null && value !== "");
if (fieldsToShow.length === 0) return null;
if (!value) return <span className="text-muted-foreground italic">Not configured</span>;
if (!SENSITIVE_FIELDS.has(key)) return <span className="font-mono text-muted-foreground">{value}</span>;
return (
<Descriptions bordered {...descriptionsConfig}>
<Descriptions.Item label="Auth Method">
<Text>{detectAuthMethod(rawValues)}</Text>
</Descriptions.Item>
{fieldsToShow.map(([key]) => (
<Descriptions.Item key={key} label={FIELD_LABELS[key] ?? key}>
{renderValue(key)}
</Descriptions.Item>
))}
</Descriptions>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-muted-foreground">{value}</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Clear ${FIELD_LABELS[key] ?? key}`}
onClick={() => setClearingField(key)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
);
};
const fieldsToShow = Object.entries(rawValues).filter(([, value]) => value != null && value !== "");
return (
<>
{isLoading ? (
<Card>
<Skeleton active />
<Card role="status" aria-label="Loading Hashicorp Vault configuration">
<CardContent className="space-y-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
) : isError ? (
<Card>
<Alert
type="error"
message="Could not load Hashicorp Vault configuration"
description={error instanceof Error ? error.message : undefined}
/>
<CardContent>
<Alert variant="error">
<AlertTitle>Could not load Hashicorp Vault configuration</AlertTitle>
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
</Alert>
</CardContent>
</Card>
) : (
<Card>
<Space direction="vertical" size="large" className="w-full">
{/* Header */}
<Flex justify="space-between" align="center">
<Flex align="center" gap={12}>
<KeyRound className="w-6 h-6 text-gray-400" />
<div>
<Title level={3} style={{ marginBottom: 0 }}>
Hashicorp Vault
</Title>
<Text type="secondary">Manage secret manager configuration</Text>
</div>
</Flex>
<Space>
{isConfigured && (
<>
<Button icon={<PlugZap className="w-4 h-4" />} loading={isTesting} onClick={handleTestConnection}>
Test Connection
</Button>
<Button icon={<Edit className="w-4 h-4" />} onClick={() => setIsEditModalVisible(true)}>
Edit Configuration
</Button>
<Button danger icon={<Trash2 className="w-4 h-4" />} onClick={() => setIsDeleteModalOpen(true)}>
Delete Configuration
</Button>
</>
)}
</Space>
</Flex>
<CardHeader>
<div className="flex items-center gap-3">
<KeyRound className="size-6 text-muted-foreground" />
<div>
<CardTitle>
<h3>Hashicorp Vault</h3>
</CardTitle>
<CardDescription>Manage secret manager configuration</CardDescription>
</div>
</div>
{isConfigured && (
<Alert
type="info"
showIcon
message={'Secrets must be stored with the field name "key"'}
description={
<>
<Text code>vault kv put secret/SECRET_NAME key=secret_value</Text>
<br />
<Typography.Link
href="https://docs.litellm.ai/docs/secret_managers/hashicorp_vault"
target="_blank"
>
View documentation
</Typography.Link>
</>
}
/>
<CardAction className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={isTesting} onClick={handleTestConnection}>
<PlugZap />
{isTesting ? "Testing..." : "Test Connection"}
</Button>
<Button type="button" variant="outline" onClick={() => setIsEditModalVisible(true)}>
<Edit />
Edit Configuration
</Button>
<Button type="button" variant="destructive" onClick={() => setIsDeleteModalOpen(true)}>
<Trash2 />
Delete Configuration
</Button>
</CardAction>
)}
</CardHeader>
<CardContent className="space-y-6">
{isConfigured && (
<Alert variant="info">
<Info />
<AlertTitle>Secrets must be stored with the field name &quot;key&quot;</AlertTitle>
<AlertDescription>
<code className="block font-mono">vault kv put secret/SECRET_NAME key=secret_value</code>
<a
href="https://docs.litellm.ai/docs/secret_managers/hashicorp_vault"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1"
>
View documentation
<ExternalLink className="size-3" />
</a>
</AlertDescription>
</Alert>
)}
{isConfigured ? (
renderSettings()
fieldsToShow.length > 0 && (
<dl className="divide-y divide-border overflow-hidden rounded-md border border-border">
<DetailRow label="Auth Method">{detectAuthMethod(rawValues)}</DetailRow>
{fieldsToShow.map(([key]) => (
<DetailRow key={key} label={FIELD_LABELS[key] ?? key}>
{renderValue(key)}
</DetailRow>
))}
</dl>
)
) : (
<HashicorpVaultEmptyPlaceholder onAdd={() => setIsEditModalVisible(true)} />
)}
</Space>
</CardContent>
</Card>
)}
@ -204,7 +197,6 @@ export default function HashicorpVault() {
onCancel={() => setIsEditModalVisible(false)}
onSuccess={() => setIsEditModalVisible(false)}
/>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Hashicorp Vault Configuration?"
@ -215,7 +207,6 @@ export default function HashicorpVault() {
onOk={handleDelete}
confirmLoading={isDeleting}
/>
<DeleteResourceModal
isOpen={clearingField !== null}
title={`Clear ${clearingField ? FIELD_LABELS[clearingField] ?? clearingField : ""}?`}

View file

@ -1,6 +1,6 @@
import { Empty, Typography, Button } from "antd";
import { KeyRound } from "lucide-react";
const { Title, Paragraph } = Typography;
import { Button } from "@/components/ui/button";
interface HashicorpVaultEmptyPlaceholderProps {
onAdd: () => void;
@ -8,22 +8,17 @@ interface HashicorpVaultEmptyPlaceholderProps {
export default function HashicorpVaultEmptyPlaceholder({ onAdd }: HashicorpVaultEmptyPlaceholderProps) {
return (
<div className="bg-white p-12 rounded-lg border border-dashed border-gray-300 text-center w-full">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div className="space-y-2">
<Title level={4}>No Vault Configuration Found</Title>
<Paragraph type="secondary" className="max-w-md mx-auto">
Configure Hashicorp Vault to securely manage provider API keys and secrets for your LiteLLM deployment.
</Paragraph>
</div>
}
>
<Button type="primary" size="large" onClick={onAdd} className="flex items-center gap-2 mx-auto mt-4">
Configure Vault
</Button>
</Empty>
<div className="flex w-full flex-col items-center rounded-lg border border-dashed border-border bg-card p-12 text-center">
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
<KeyRound className="size-6 text-muted-foreground" />
</div>
<h4 className="text-base font-semibold text-foreground">No Vault Configuration Found</h4>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
Configure Hashicorp Vault to securely manage provider API keys and secrets for your LiteLLM deployment.
</p>
<Button size="lg" onClick={onAdd} className="mt-4">
Configure Vault
</Button>
</div>
);
}

View file

@ -1,19 +1,19 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
import RedactableField from "./RedactableField";
describe("RedactableField", () => {
describe("when value is null", () => {
it("should display 'Not configured' text", () => {
render(<RedactableField value={null} />);
renderWithProviders(<RedactableField value={null} />);
expect(screen.getByText("Not configured")).toBeInTheDocument();
});
it("should not display toggle button", () => {
render(<RedactableField value={null} />);
// There should be no button elements
renderWithProviders(<RedactableField value={null} />);
const buttons = screen.queryAllByRole("button");
expect(buttons).toHaveLength(0);
});
@ -23,73 +23,49 @@ describe("RedactableField", () => {
const testValue = "secret-password";
it("should be hidden by default and show redacted dots", () => {
render(<RedactableField value={testValue} />);
// Should show dots equal to the length of the value
renderWithProviders(<RedactableField value={testValue} />);
expect(screen.getByText("•".repeat(testValue.length))).toBeInTheDocument();
expect(screen.queryByText(testValue)).not.toBeInTheDocument();
});
it("should show actual value when defaultHidden is false", () => {
render(<RedactableField value={testValue} defaultHidden={false} />);
renderWithProviders(<RedactableField value={testValue} defaultHidden={false} />);
expect(screen.getByText(testValue)).toBeInTheDocument();
expect(screen.queryByText("•".repeat(testValue.length))).not.toBeInTheDocument();
});
it("should display toggle button with eye icon when hidden", () => {
render(<RedactableField value={testValue} />);
it("should identify the hidden-value control and render its icon", () => {
renderWithProviders(<RedactableField value={testValue} />);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
// Check that the Eye icon is rendered (we can check by title or by the presence of the icon)
// The button should contain the Eye icon when hidden
const eyeIcon = button.querySelector("svg");
expect(eyeIcon).toBeInTheDocument();
const button = screen.getByRole("button", { name: "Show value" });
expect(button.querySelector("svg")).toBeInTheDocument();
});
it("should display toggle button with eye-off icon when shown", () => {
render(<RedactableField value={testValue} defaultHidden={false} />);
it("should identify the visible-value control and render its icon", () => {
renderWithProviders(<RedactableField value={testValue} defaultHidden={false} />);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
// The button should contain the EyeOff icon when shown
const eyeOffIcon = button.querySelector("svg");
expect(eyeOffIcon).toBeInTheDocument();
const button = screen.getByRole("button", { name: "Hide value" });
expect(button.querySelector("svg")).toBeInTheDocument();
});
it("should toggle visibility when button is clicked", () => {
render(<RedactableField value={testValue} />);
it("should toggle visibility when button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<RedactableField value={testValue} />);
// Initially hidden
expect(screen.getByText("•".repeat(testValue.length))).toBeInTheDocument();
expect(screen.queryByText(testValue)).not.toBeInTheDocument();
// Click to show
const button = screen.getByRole("button");
fireEvent.click(button);
// Should now show the actual value
await user.click(screen.getByRole("button", { name: "Show value" }));
expect(screen.getByText(testValue)).toBeInTheDocument();
expect(screen.queryByText("•".repeat(testValue.length))).not.toBeInTheDocument();
// Click again to hide
fireEvent.click(button);
// Should be hidden again
await user.click(screen.getByRole("button", { name: "Hide value" }));
expect(screen.getByText("•".repeat(testValue.length))).toBeInTheDocument();
expect(screen.queryByText(testValue)).not.toBeInTheDocument();
});
it("should handle empty string value", () => {
render(<RedactableField value="" />);
// Empty string should show "Not configured" since value is falsy
renderWithProviders(<RedactableField value="" />);
expect(screen.getByText("Not configured")).toBeInTheDocument();
// No toggle button for empty string
const buttons = screen.queryAllByRole("button");
expect(buttons).toHaveLength(0);
});
@ -98,7 +74,7 @@ describe("RedactableField", () => {
const shortValue = "hi";
const longValue = "this-is-a-very-long-secret-value";
const { rerender } = render(<RedactableField value={shortValue} />);
const { rerender } = renderWithProviders(<RedactableField value={shortValue} />);
expect(screen.getByText("••")).toBeInTheDocument();
rerender(<RedactableField value={longValue} />);

View file

@ -1,7 +1,8 @@
import { useState } from "react";
import { Button } from "antd";
import { Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function RedactableField({
defaultHidden = true,
value,
@ -13,7 +14,7 @@ export default function RedactableField({
return (
<div className="flex items-center gap-2">
<span className="font-mono text-gray-600 flex-1">
<span className="flex-1 font-mono text-muted-foreground">
{value ? (
isHidden ? (
"•".repeat(value.length)
@ -21,17 +22,20 @@ export default function RedactableField({
value
)
) : (
<span className="text-gray-400 italic">Not configured</span>
<span className="text-muted-foreground 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" />}
type="button"
variant="ghost"
size="icon-sm"
aria-label={isHidden ? "Show value" : "Hide value"}
onClick={() => setIsHidden(!isHidden)}
className="text-gray-400 hover:text-gray-600"
/>
className="text-muted-foreground"
>
{isHidden ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
</Button>
)}
</div>
);

View file

@ -1,11 +1,15 @@
"use client";
import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { Button, Card, Descriptions, Space, Tag, Typography } from "antd";
import { Edit, Shield, Trash2 } from "lucide-react";
import { Copy, Edit, Shield, Trash2 } from "lucide-react";
import { useState } from "react";
import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { Logo } from "@/components/molecules/logo/Logo";
import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/dataUtils";
import AddSSOSettingsModal from "./Modals/AddSSOSettingsModal";
import DeleteSSOSettingsModal from "./Modals/DeleteSSOSettingsModal";
import EditSSOSettingsModal from "./Modals/EditSSOSettingsModal";
@ -13,9 +17,40 @@ import RedactableField from "./RedactableField";
import RoleMappings from "./RoleMappings";
import SSOSettingsEmptyPlaceholder from "./SSOSettingsEmptyPlaceholder";
import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton";
import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants";
import { detectSSOProvider } from "./utils";
const { Title, Text } = Typography;
function NotConfigured() {
return <span className="text-muted-foreground italic">Not configured</span>;
}
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3">
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium text-foreground">{label}</dt>
<dd className="min-w-0 px-4 py-3 text-sm text-foreground sm:col-span-2">{children}</dd>
</div>
);
}
function EndpointValue({ value }: { value?: string | null }) {
if (!value) return <span className="font-mono text-muted-foreground">-</span>;
return (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-sm text-muted-foreground">{value}</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Copy value"
onClick={() => void copyToClipboard(value, "Copied to clipboard")}
>
<Copy className="size-3.5" />
</Button>
</div>
);
}
export default function SSOSettings() {
const { data: ssoSettings, refetch, isLoading } = useSSOSettings();
@ -29,37 +64,17 @@ export default function SSOSettings() {
ssoSettings?.values.saml_idp_metadata_url,
ssoSettings?.values.saml_idp_metadata_xml,
].some(Boolean);
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) => (
<Text className="font-mono text-gray-600 text-sm" copyable={!!value}>
{value || "-"}
</Text>
);
const renderSimpleValue = (value?: string | null) =>
value ? value : <span className="text-gray-400 italic">Not configured</span>;
const renderTeamMappingsField = (values: SSOSettingsValues) => {
if (!values.team_mappings?.team_ids_jwt_field) {
return <span className="text-gray-400 italic">Not configured</span>;
}
return <Tag>{values.team_mappings.team_ids_jwt_field}</Tag>;
};
const descriptionsConfig = {
column: {
xxl: 1,
xl: 1,
lg: 1,
md: 1,
sm: 1,
xs: 1,
},
};
const renderSimpleValue = (value?: string | null) => value || <NotConfigured />;
const renderTeamMappingsField = (values: SSOSettingsValues) =>
values.team_mappings?.team_ids_jwt_field ? (
<Badge variant="secondary">{values.team_mappings.team_ids_jwt_field}</Badge>
) : (
<NotConfigured />
);
const providerConfigs = {
google: {
@ -87,7 +102,7 @@ export default function SSOSettings() {
label: "Client Secret",
render: (values: SSOSettingsValues) => <RedactableField value={values.microsoft_client_secret} />,
},
{ label: "Tenant", render: (values: any) => renderSimpleValue(values.microsoft_tenant) },
{ label: "Tenant", render: (values: SSOSettingsValues) => renderSimpleValue(values.microsoft_tenant) },
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
],
},
@ -104,23 +119,20 @@ export default function SSOSettings() {
},
{
label: "Authorization Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_authorization_endpoint),
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_authorization_endpoint} />,
},
{
label: "Token Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_token_endpoint),
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_token_endpoint} />,
},
{
label: "User Info Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_userinfo_endpoint} />,
},
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
isTeamMappingsEnabled
? {
label: "Team IDs JWT Field",
render: (values: SSOSettingsValues) => renderTeamMappingsField(values),
}
? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) }
: null,
],
},
@ -137,23 +149,20 @@ export default function SSOSettings() {
},
{
label: "Authorization Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_authorization_endpoint),
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_authorization_endpoint} />,
},
{
label: "Token Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_token_endpoint),
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_token_endpoint} />,
},
{
label: "User Info Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_userinfo_endpoint} />,
},
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
isTeamMappingsEnabled
? {
label: "Team IDs JWT Field",
render: (values: SSOSettingsValues) => renderTeamMappingsField(values),
}
? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) }
: null,
],
},
@ -162,27 +171,23 @@ export default function SSOSettings() {
fields: [
{
label: "IdP Metadata URL",
render: (values: SSOSettingsValues) => renderEndpointValue(values.saml_idp_metadata_url),
render: (values: SSOSettingsValues) => <EndpointValue value={values.saml_idp_metadata_url} />,
},
{
label: "IdP Metadata XML",
render: (values: SSOSettingsValues) =>
values.saml_idp_metadata_xml ? (
<Tag>Provided</Tag>
) : (
<span className="text-gray-400 italic">Not configured</span>
),
values.saml_idp_metadata_xml ? <Badge variant="secondary">Provided</Badge> : <NotConfigured />,
},
{
label: "SP Entity ID",
render: (values: SSOSettingsValues) => renderEndpointValue(values.saml_sp_entity_id),
render: (values: SSOSettingsValues) => <EndpointValue value={values.saml_sp_entity_id} />,
},
{
label: "Allow IdP-initiated (unsolicited) responses",
render: (values: SSOSettingsValues) => (
<Tag color={values.saml_allow_unsolicited === "true" ? "green" : "default"}>
<Badge variant={values.saml_allow_unsolicited === "true" ? "default" : "secondary"}>
{values.saml_allow_unsolicited === "true" ? "Enabled" : "Disabled"}
</Tag>
</Badge>
),
},
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
@ -192,35 +197,32 @@ export default function SSOSettings() {
const renderSSOSettings = () => {
if (!ssoSettings?.values || !selectedProvider) return null;
const { values } = ssoSettings;
const config = providerConfigs[selectedProvider as keyof typeof providerConfigs];
if (!config) return null;
return (
<Descriptions bordered {...descriptionsConfig}>
<Descriptions.Item label="Provider">
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<dl className="divide-y divide-border overflow-hidden rounded-md border border-border">
<DetailRow label="Provider">
<div className="flex items-center gap-2">
{ssoProviderLogoMap[selectedProvider] && (
<Logo
src={ssoProviderLogoMap[selectedProvider]}
label={ssoProviderDisplayNames[selectedProvider] || selectedProvider}
className="h-6 w-6 object-contain"
className="size-6 object-contain"
/>
)}
<span>{config.providerText}</span>
</div>
</Descriptions.Item>
</DetailRow>
{config.fields.map(
(field, index) =>
(field) =>
field && (
<Descriptions.Item key={index} label={field.label}>
{field.render(values)}
</Descriptions.Item>
<DetailRow key={field.label} label={field.label}>
{field.render(ssoSettings.values)}
</DetailRow>
),
)}
</Descriptions>
</dl>
);
};
@ -229,46 +231,41 @@ export default function SSOSettings() {
{isLoading ? (
<SSOSettingsLoadingSkeleton />
) : (
<Space direction="vertical" size="large" className="w-full">
<div className="space-y-6">
<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>
</>
)}
<CardHeader>
<div className="flex items-center gap-3">
<Shield className="size-6 text-muted-foreground" />
<div>
<CardTitle>
<h3>SSO Configuration</h3>
</CardTitle>
<CardDescription>Manage Single Sign-On authentication settings</CardDescription>
</div>
</div>
{isSSOConfigured && (
<CardAction className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setIsEditModalVisible(true)}>
<Edit />
Edit SSO Settings
</Button>
<Button type="button" variant="destructive" onClick={() => setIsDeleteModalVisible(true)}>
<Trash2 />
Delete SSO Settings
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{isSSOConfigured ? (
renderSSOSettings()
) : (
<SSOSettingsEmptyPlaceholder onAdd={() => setIsAddModalVisible(true)} />
)}
</Space>
</CardContent>
</Card>
{isRoleMappingsEnabled && <RoleMappings roleMappings={ssoSettings?.values.role_mappings} />}
</Space>
</div>
)}
<DeleteSSOSettingsModal
@ -276,7 +273,6 @@ export default function SSOSettings() {
onCancel={() => setIsDeleteModalVisible(false)}
onSuccess={() => refetch()}
/>
<AddSSOSettingsModal
isVisible={isAddModalVisible}
onCancel={() => setIsAddModalVisible(false)}
@ -285,7 +281,6 @@ export default function SSOSettings() {
refetch();
}}
/>
<EditSSOSettingsModal
isVisible={isEditModalVisible}
onCancel={() => setIsEditModalVisible(false)}

View file

@ -1,6 +1,6 @@
import { Empty, Typography, Button } from "antd";
import { Shield } from "lucide-react";
const { Title, Paragraph } = Typography;
import { Button } from "@/components/ui/button";
interface SSOSettingsEmptyPlaceholderProps {
onAdd: () => void;
@ -8,23 +8,18 @@ interface SSOSettingsEmptyPlaceholderProps {
export default function SSOSettingsEmptyPlaceholder({ onAdd }: SSOSettingsEmptyPlaceholderProps) {
return (
<div className="bg-white p-12 rounded-lg border border-dashed border-gray-300 text-center w-full">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div className="space-y-2">
<Title level={4}>No SSO Configuration Found</Title>
<Paragraph type="secondary" className="max-w-md mx-auto">
Configure Single Sign-On (SSO) to enable seamless authentication for your team members using your identity
provider.
</Paragraph>
</div>
}
>
<Button type="primary" size="large" onClick={onAdd} className="flex items-center gap-2 mx-auto mt-4">
Configure SSO
</Button>
</Empty>
<div className="flex w-full flex-col items-center rounded-lg border border-dashed border-border bg-card p-12 text-center">
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
<Shield className="size-6 text-muted-foreground" />
</div>
<h4 className="text-base font-semibold text-foreground">No SSO Configuration Found</h4>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
Configure Single Sign-On (SSO) to enable seamless authentication for your team members using your identity
provider.
</p>
<Button size="lg" onClick={onAdd} className="mt-4">
Configure SSO
</Button>
</div>
);
}

View file

@ -1,222 +1,31 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton";
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Shield: ({ className }: any) => <div data-testid="shield-icon" className={className} />,
}));
// Mock Ant Design components
vi.mock("antd", () => ({
Card: ({ children, ...props }: any) => (
<div data-testid="card" {...props}>
{children}
</div>
),
Descriptions: Object.assign(
({ children, bordered, column, ...props }: any) => (
<div data-testid="descriptions" data-bordered={bordered} data-column={JSON.stringify(column)} {...props}>
{children}
</div>
),
{
Item: ({ children, label, ...props }: any) => (
<div data-testid="descriptions-item" {...props}>
<div data-testid="descriptions-item-label">{label}</div>
<div data-testid="descriptions-item-content">{children}</div>
</div>
),
},
),
Typography: {
Title: ({ children, level, ...props }: any) => (
<div data-testid="typography-title" data-level={level} {...props}>
{children}
</div>
),
Text: ({ children, type, ...props }: any) => (
<div data-testid="typography-text" data-type={type} {...props}>
{children}
</div>
),
},
Space: ({ children, direction, size, className, ...props }: any) => (
<div data-testid="space" data-direction={direction} data-size={size} className={className} {...props}>
{children}
</div>
),
Skeleton: {
Button: ({ active, size, style, ...props }: any) => (
<div
data-testid="skeleton-button"
data-active={active}
data-size={size}
data-style={JSON.stringify(style)}
{...props}
>
Button Skeleton
</div>
),
Node: ({ active, style, ...props }: any) => (
<div data-testid="skeleton-node" data-active={active} data-style={JSON.stringify(style)} {...props}>
Node Skeleton
</div>
),
},
}));
describe("SSOSettingsLoadingSkeleton", () => {
it("should render without crashing", () => {
expect(() => render(<SSOSettingsLoadingSkeleton />)).not.toThrow();
it("should render", () => {
renderWithProviders(<SSOSettingsLoadingSkeleton />);
expect(screen.getByRole("heading", { name: "SSO Configuration" })).toBeInTheDocument();
expect(screen.getByRole("status", { name: "Loading SSO configuration" })).toBeInTheDocument();
});
it("should render Card component", () => {
render(<SSOSettingsLoadingSkeleton />);
expect(screen.getByTestId("card")).toBeInTheDocument();
it("should explain which configuration is loading", () => {
renderWithProviders(<SSOSettingsLoadingSkeleton />);
expect(screen.getByText("Manage Single Sign-On authentication settings")).toBeInTheDocument();
});
it("should render Space component with correct props", () => {
render(<SSOSettingsLoadingSkeleton />);
const space = screen.getByTestId("space");
expect(space).toBeInTheDocument();
expect(space).toHaveAttribute("data-direction", "vertical");
expect(space).toHaveAttribute("data-size", "large");
expect(space).toHaveClass("w-full");
});
it("should render the complete action and configuration skeleton", () => {
const { container } = renderWithProviders(<SSOSettingsLoadingSkeleton />);
describe("Header Section", () => {
it("should render Shield icon", () => {
render(<SSOSettingsLoadingSkeleton />);
const shieldIcon = screen.getByTestId("shield-icon");
expect(shieldIcon).toBeInTheDocument();
expect(shieldIcon).toHaveClass("w-6 h-6 text-gray-400");
});
it("should render title with correct text and level", () => {
render(<SSOSettingsLoadingSkeleton />);
const title = screen.getByTestId("typography-title");
expect(title).toBeInTheDocument();
expect(title).toHaveAttribute("data-level", "3");
expect(title).toHaveTextContent("SSO Configuration");
});
it("should render subtitle text", () => {
render(<SSOSettingsLoadingSkeleton />);
const text = screen.getByTestId("typography-text");
expect(text).toBeInTheDocument();
expect(text).toHaveAttribute("data-type", "secondary");
expect(text).toHaveTextContent("Manage Single Sign-On authentication settings");
});
it("should render two skeleton buttons with correct styles", () => {
render(<SSOSettingsLoadingSkeleton />);
const buttons = screen.getAllByTestId("skeleton-button");
expect(buttons).toHaveLength(2);
// First button
expect(buttons[0]).toHaveAttribute("data-active", "true");
expect(buttons[0]).toHaveAttribute("data-size", "default");
expect(buttons[0]).toHaveAttribute("data-style", JSON.stringify({ width: 170, height: 32 }));
// Second button
expect(buttons[1]).toHaveAttribute("data-active", "true");
expect(buttons[1]).toHaveAttribute("data-size", "default");
expect(buttons[1]).toHaveAttribute("data-style", JSON.stringify({ width: 190, height: 32 }));
});
});
describe("Descriptions Table", () => {
it("should render Descriptions component with bordered prop", () => {
render(<SSOSettingsLoadingSkeleton />);
const descriptions = screen.getByTestId("descriptions");
expect(descriptions).toBeInTheDocument();
expect(descriptions).toHaveAttribute("data-bordered", "true");
});
it("should apply correct column configuration", () => {
render(<SSOSettingsLoadingSkeleton />);
const descriptions = screen.getByTestId("descriptions");
const expectedColumn = {
xxl: 1,
xl: 1,
lg: 1,
md: 1,
sm: 1,
xs: 1,
};
expect(descriptions).toHaveAttribute("data-column", JSON.stringify(expectedColumn));
});
it("should render exactly 5 description items", () => {
render(<SSOSettingsLoadingSkeleton />);
const items = screen.getAllByTestId("descriptions-item");
expect(items).toHaveLength(5);
});
describe("Description Items Structure", () => {
it("should render exactly 10 skeleton nodes total", () => {
render(<SSOSettingsLoadingSkeleton />);
const skeletonNodes = screen.getAllByTestId("skeleton-node");
expect(skeletonNodes).toHaveLength(10);
});
it("should render 5 skeleton nodes for labels with width 80", () => {
render(<SSOSettingsLoadingSkeleton />);
const skeletonNodes = screen.getAllByTestId("skeleton-node");
const labelNodes = skeletonNodes.filter(
(node) => node.getAttribute("data-style") === JSON.stringify({ width: 80, height: 16 }),
);
expect(labelNodes).toHaveLength(5);
labelNodes.forEach((node) => {
expect(node).toHaveAttribute("data-active", "true");
});
});
it("should render skeleton nodes for content with correct widths", () => {
render(<SSOSettingsLoadingSkeleton />);
const skeletonNodes = screen.getAllByTestId("skeleton-node");
// Expected content widths: [100, 200, 250, 180, 220]
const expectedWidths = [100, 200, 250, 180, 220];
expectedWidths.forEach((width) => {
const contentNode = skeletonNodes.find(
(node) => node.getAttribute("data-style") === JSON.stringify({ width, height: 16 }),
);
expect(contentNode).toBeInTheDocument();
expect(contentNode).toHaveAttribute("data-active", "true");
});
});
});
});
describe("Accessibility and Structure", () => {
it("should have proper semantic structure", () => {
render(<SSOSettingsLoadingSkeleton />);
// Card contains Space
const card = screen.getByTestId("card");
const space = screen.getByTestId("space");
expect(card).toContainElement(space);
// Space contains header section and descriptions
const descriptions = screen.getByTestId("descriptions");
expect(space).toContainElement(descriptions);
});
it("should render all skeleton elements as active", () => {
render(<SSOSettingsLoadingSkeleton />);
const skeletonNodes = screen.getAllByTestId("skeleton-node");
const skeletonButtons = screen.getAllByTestId("skeleton-button");
skeletonNodes.forEach((node) => {
expect(node).toHaveAttribute("data-active", "true");
});
skeletonButtons.forEach((button) => {
expect(button).toHaveAttribute("data-active", "true");
});
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons).toHaveLength(12);
expect(container.querySelectorAll('[data-slot="skeleton"].h-8')).toHaveLength(2);
expect(container.querySelectorAll('[data-slot="skeleton"].h-4.w-20')).toHaveLength(5);
["w-24", "w-48", "w-60", "w-44", "w-52"].forEach((width) => {
expect(container.querySelector(`[data-slot="skeleton"].h-4.${width}`)).toBeInTheDocument();
});
});
});

View file

@ -1,66 +1,42 @@
"use client";
import { Card, Descriptions, Skeleton, Space, Typography } from "antd";
import { Shield } from "lucide-react";
const { Title, Text } = Typography;
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
const CONTENT_WIDTHS = ["w-24", "w-48", "w-60", "w-44", "w-52"];
export default function SSOSettingsLoadingSkeleton() {
const descriptionsConfig = {
column: {
xxl: 1,
xl: 1,
lg: 1,
md: 1,
sm: 1,
xs: 1,
},
};
return (
<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">
<Skeleton.Button active size="default" style={{ width: 170, height: 32 }} />
<Skeleton.Button active size="default" style={{ width: 190, height: 32 }} />
<Card role="status" aria-label="Loading SSO configuration">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="size-6 text-muted-foreground" />
<div>
<h3 className="text-lg font-semibold text-foreground">SSO Configuration</h3>
<p className="text-sm text-muted-foreground">Manage Single Sign-On authentication settings</p>
</div>
</div>
{/* Descriptions Table Skeleton */}
<Descriptions bordered {...descriptionsConfig}>
{/* Provider Row */}
<Descriptions.Item label={<Skeleton.Node active style={{ width: 80, height: 16 }} />}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Skeleton.Node active style={{ width: 100, height: 16 }} />
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-8 w-48" />
</div>
</CardHeader>
<CardContent>
<div className="divide-y divide-border overflow-hidden rounded-md border border-border">
{CONTENT_WIDTHS.map((width) => (
<div key={width} className="grid grid-cols-3">
<div className="bg-muted/50 px-4 py-3">
<Skeleton className="h-4 w-20" />
</div>
<div className="col-span-2 px-4 py-3">
<Skeleton className={`h-4 ${width}`} />
</div>
</div>
</Descriptions.Item>
<Descriptions.Item label={<Skeleton.Node active style={{ width: 80, height: 16 }} />}>
<Skeleton.Node active style={{ width: 200, height: 16 }} />
</Descriptions.Item>
<Descriptions.Item label={<Skeleton.Node active style={{ width: 80, height: 16 }} />}>
<Skeleton.Node active style={{ width: 250, height: 16 }} />
</Descriptions.Item>
<Descriptions.Item label={<Skeleton.Node active style={{ width: 80, height: 16 }} />}>
<Skeleton.Node active style={{ width: 180, height: 16 }} />
</Descriptions.Item>
<Descriptions.Item label={<Skeleton.Node active style={{ width: 80, height: 16 }} />}>
<Skeleton.Node active style={{ width: 220, height: 16 }} />
</Descriptions.Item>
</Descriptions>
</Space>
))}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,6 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "@/../tests/test-utils";
import PageVisibilitySettings from "./PageVisibilitySettings";
vi.mock("@/components/page_utils", () => ({
@ -13,26 +15,32 @@ vi.mock("@/components/page_utils", () => ({
describe("PageVisibilitySettings", () => {
it("should render the not-set tag when enabledPagesInternalUsers is null", () => {
render(<PageVisibilitySettings enabledPagesInternalUsers={null} isUpdating={false} onUpdate={vi.fn()} />);
renderWithProviders(
<PageVisibilitySettings enabledPagesInternalUsers={null} isUpdating={false} onUpdate={vi.fn()} />,
);
expect(screen.getByText("Not set (all pages visible)")).toBeInTheDocument();
});
it("should show the selected page count tag when pages are configured", () => {
render(
renderWithProviders(
<PageVisibilitySettings enabledPagesInternalUsers={["usage", "keys"]} isUpdating={false} onUpdate={vi.fn()} />,
);
expect(screen.getByText("2 pages selected")).toBeInTheDocument();
});
it("should show singular 'page' when exactly one page is selected", () => {
render(<PageVisibilitySettings enabledPagesInternalUsers={["usage"]} isUpdating={false} onUpdate={vi.fn()} />);
renderWithProviders(
<PageVisibilitySettings enabledPagesInternalUsers={["usage"]} isUpdating={false} onUpdate={vi.fn()} />,
);
expect(screen.getByText("1 page selected")).toBeInTheDocument();
});
it("should call onUpdate with null when reset button is clicked", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
render(<PageVisibilitySettings enabledPagesInternalUsers={["usage"]} isUpdating={false} onUpdate={onUpdate} />);
renderWithProviders(
<PageVisibilitySettings enabledPagesInternalUsers={["usage"]} isUpdating={false} onUpdate={onUpdate} />,
);
// Expand the collapse panel first to reveal the reset button
await user.click(screen.getByRole("button", { name: /configure page visibility/i }));
@ -41,8 +49,34 @@ describe("PageVisibilitySettings", () => {
expect(onUpdate).toHaveBeenCalledWith({ enabled_ui_pages_internal_users: null });
});
it("should render every page under its original group when Object.groupBy is unavailable", async () => {
const groupByDescriptor = Object.getOwnPropertyDescriptor(Object, "groupBy");
Object.defineProperty(Object, "groupBy", { configurable: true, value: undefined });
try {
const user = userEvent.setup();
renderWithProviders(
<PageVisibilitySettings enabledPagesInternalUsers={null} isUpdating={false} onUpdate={vi.fn()} />,
);
await user.click(screen.getByRole("button", { name: /configure page visibility/i }));
expect(screen.getByRole("group", { name: "Analytics" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Access" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /usage/i })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /models/i })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /api keys/i })).toBeInTheDocument();
} finally {
if (groupByDescriptor) {
Object.defineProperty(Object, "groupBy", groupByDescriptor);
} else {
Reflect.deleteProperty(Object, "groupBy");
}
}
});
it("should display the property description when provided", () => {
render(
renderWithProviders(
<PageVisibilitySettings
enabledPagesInternalUsers={null}
enabledPagesPropertyDescription="Controls which pages are visible"

View file

@ -1,9 +1,14 @@
"use client";
import { getAvailablePages } from "@/components/page_utils";
import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd";
import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { getAvailablePages } from "@/components/page_utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface PageVisibilitySettingsProps {
enabledPagesInternalUsers: string[] | null | undefined;
enabledPagesPropertyDescription?: string;
@ -17,13 +22,8 @@ export default function PageVisibilitySettings({
isUpdating,
onUpdate,
}: PageVisibilitySettingsProps) {
// Check if page visibility is set (null/undefined means "not set" = all pages visible)
const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined;
// Get available pages from leftnav configuration
const availablePages = useMemo(() => getAvailablePages(), []);
// Group pages by their group for better UI
const pagesByGroup = useMemo(() => {
const grouped: Record<string, typeof availablePages> = {};
availablePages.forEach((page) => {
@ -34,19 +34,16 @@ export default function PageVisibilitySettings({
});
return grouped;
}, [availablePages]);
// Local state for page selection
const [selectedPages, setSelectedPages] = useState<string[]>(enabledPagesInternalUsers || []);
// Update local state when data changes
useMemo(() => {
if (enabledPagesInternalUsers) {
setSelectedPages(enabledPagesInternalUsers);
} else {
setSelectedPages([]);
}
setSelectedPages(enabledPagesInternalUsers || []);
}, [enabledPagesInternalUsers]);
const togglePage = (page: string, checked: boolean) => {
setSelectedPages((current) => (checked ? [...current, page] : current.filter((item) => item !== page)));
};
const handleSavePageVisibility = () => {
onUpdate({ enabled_ui_pages_internal_users: selectedPages.length > 0 ? selectedPages : null });
};
@ -57,90 +54,74 @@ export default function PageVisibilitySettings({
};
return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space direction="vertical" size={4}>
<Space align="center">
<Typography.Text strong>Internal User Page Visibility</Typography.Text>
{!isPageVisibilitySet && (
<Tag color="default" style={{ marginLeft: "8px" }}>
Not set (all pages visible)
</Tag>
)}
{isPageVisibilitySet && (
<Tag color="blue" style={{ marginLeft: "8px" }}>
{selectedPages.length} page{selectedPages.length !== 1 ? "s" : ""} selected
</Tag>
)}
</Space>
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">Internal User Page Visibility</p>
<Badge variant={isPageVisibilitySet ? "secondary" : "outline"}>
{isPageVisibilitySet
? `${selectedPages.length} page${selectedPages.length !== 1 ? "s" : ""} selected`
: "Not set (all pages visible)"}
</Badge>
</div>
{enabledPagesPropertyDescription && (
<Typography.Text type="secondary">{enabledPagesPropertyDescription}</Typography.Text>
<p className="text-sm text-muted-foreground">{enabledPagesPropertyDescription}</p>
)}
<Typography.Text type="secondary" style={{ fontSize: "12px", fontStyle: "italic" }}>
<p className="text-xs italic text-muted-foreground">
By default, all pages are visible to internal users. Select specific pages to restrict visibility.
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: "12px", color: "#8b5cf6" }}>
</p>
<p className="text-xs text-primary">
Note: Only pages accessible to internal user roles are shown here. Admin-only pages are excluded as they
cannot be made visible to internal users regardless of this setting.
</Typography.Text>
</Space>
</p>
</div>
<Collapse
items={[
{
key: "page-visibility",
label: "Configure Page Visibility",
children: (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Checkbox.Group value={selectedPages} onChange={setSelectedPages} style={{ width: "100%" }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
{Object.entries(pagesByGroup).map(([groupName, pages]) => (
<div key={groupName}>
<Typography.Text
strong
style={{
fontSize: "11px",
color: "#6b7280",
letterSpacing: "0.05em",
display: "block",
marginBottom: "8px",
}}
>
{groupName}
</Typography.Text>
<Space direction="vertical" size="small" style={{ marginLeft: "16px", width: "100%" }}>
{pages.map((page) => (
<div key={page.page} style={{ marginBottom: "4px" }}>
<Checkbox value={page.page}>
<Space direction="vertical" size={0}>
<Typography.Text>{page.label}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
{page.description}
</Typography.Text>
</Space>
</Checkbox>
</div>
))}
</Space>
</div>
))}
</Space>
</Checkbox.Group>
<Collapsible className="rounded-lg border border-border">
<CollapsibleTrigger className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium hover:bg-muted">
Configure Page Visibility
<ChevronDown className="size-4 transition-transform group-data-[panel-open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border p-4">
<div className="space-y-4">
{Object.entries(pagesByGroup).map(([groupName, pages]) => (
<fieldset key={groupName} className="space-y-2">
<legend className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{groupName}
</legend>
<div className="ml-4 space-y-2">
{pages.map((page) => {
const checkboxId = `page-visibility-${page.page}`;
return (
<label key={page.page} htmlFor={checkboxId} className="flex cursor-pointer items-start gap-2">
<Checkbox
id={checkboxId}
checked={selectedPages.includes(page.page)}
onCheckedChange={(checked) => togglePage(page.page, checked === true)}
/>
<span className="space-y-0.5">
<span className="block text-sm text-foreground">{page.label}</span>
<span className="block text-xs text-muted-foreground">{page.description}</span>
</span>
</label>
);
})}
</div>
</fieldset>
))}
<Space>
<Button type="primary" onClick={handleSavePageVisibility} loading={isUpdating} disabled={isUpdating}>
Save Page Visibility Settings
</Button>
{isPageVisibilitySet && (
<Button onClick={handleResetToDefault} loading={isUpdating} disabled={isUpdating}>
Reset to Default (All Pages)
</Button>
)}
</Space>
</Space>
),
},
]}
/>
</Space>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={handleSavePageVisibility} disabled={isUpdating}>
Save Page Visibility Settings
</Button>
{isPageVisibilitySet && (
<Button type="button" variant="outline" onClick={handleResetToDefault} disabled={isUpdating}>
Reset to Default (All Pages)
</Button>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -4,8 +4,46 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import NotificationManager from "@/components/molecules/notifications_manager";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import PageVisibilitySettings from "./PageVisibilitySettings";
import { Alert, Card, Divider, Skeleton, Space, Switch, Typography } from "antd";
interface SettingRowProps {
ariaLabel: string;
checked: boolean;
description?: string;
disabled: boolean;
indented?: boolean;
label: string;
muted?: boolean;
onCheckedChange: (checked: boolean) => void;
}
function SettingRow({
ariaLabel,
checked,
description,
disabled,
indented = false,
label,
muted = false,
onCheckedChange,
}: SettingRowProps) {
return (
<div className={indented ? "ml-8 flex items-start gap-3" : "flex items-start gap-3"}>
<Switch checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} aria-label={ariaLabel} />
<div className="space-y-1">
<p className={muted ? "text-sm font-medium text-muted-foreground" : "text-sm font-medium text-foreground"}>
{label}
</p>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
</div>
);
}
export default function UISettings() {
const { accessToken } = useAuthorized();
@ -229,270 +267,181 @@ export default function UISettings() {
};
return (
<Card title="UI Settings">
{isLoading ? (
<Skeleton active />
) : isError ? (
<Alert
type="error"
message="Could not load UI settings"
description={error instanceof Error ? error.message : undefined}
/>
) : (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
{schema?.description && (
<Typography.Paragraph style={{ marginBottom: 0 }}>{schema.description}</Typography.Paragraph>
)}
<Card>
<CardHeader>
<CardTitle>
<h3>UI Settings</h3>
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div role="status" aria-label="Loading UI settings" className="space-y-3">
<Skeleton className="h-5 w-72" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : isError ? (
<Alert variant="error">
<AlertTitle>Could not load UI settings</AlertTitle>
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
</Alert>
) : (
<div className="space-y-6">
{schema?.description && <p className="text-sm text-foreground">{schema.description}</p>}
{updateError && (
<Alert variant="error">
<AlertTitle>Could not update UI settings</AlertTitle>
{updateError instanceof Error && <AlertDescription>{updateError.message}</AlertDescription>}
</Alert>
)}
{updateError && (
<Alert
type="error"
message="Could not update UI settings"
description={updateError instanceof Error ? updateError.message : undefined}
/>
)}
<Space align="start" size="middle">
<Switch
<SettingRow
checked={isDisabledForInternalUsers}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggle}
aria-label={property?.description ?? "Disable model add for internal users"}
onCheckedChange={handleToggle}
ariaLabel={property?.description ?? "Disable model add for internal users"}
label="Disable model add for internal users"
description={property?.description}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Disable model add for internal users</Typography.Text>
{property?.description && <Typography.Text type="secondary">{property.description}</Typography.Text>}
</Space>
</Space>
<Space align="start" size="middle">
<Switch
<SettingRow
checked={isDisabledTeamAdminDeleteTeamUser}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleTeamAdminDelete}
aria-label={disableTeamAdminDeleteProperty?.description ?? "Disable team admin delete team user"}
onCheckedChange={handleToggleTeamAdminDelete}
ariaLabel={disableTeamAdminDeleteProperty?.description ?? "Disable team admin delete team user"}
label="Disable team admin delete team user"
description={disableTeamAdminDeleteProperty?.description}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Disable team admin delete team user</Typography.Text>
{disableTeamAdminDeleteProperty?.description && (
<Typography.Text type="secondary">{disableTeamAdminDeleteProperty.description}</Typography.Text>
)}
</Space>
</Space>
<Space align="start" size="middle">
<Switch
checked={values.require_auth_for_public_ai_hub}
<SettingRow
checked={Boolean(values.require_auth_for_public_ai_hub)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleRequireAuthForPublicAIHub}
aria-label={requireAuthForPublicAIHubProperty?.description ?? "Require authentication for public AI Hub"}
onCheckedChange={handleToggleRequireAuthForPublicAIHub}
ariaLabel={requireAuthForPublicAIHubProperty?.description ?? "Require authentication for public AI Hub"}
label="Require authentication for public AI Hub"
description={requireAuthForPublicAIHubProperty?.description}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Require authentication for public AI Hub</Typography.Text>
{requireAuthForPublicAIHubProperty?.description && (
<Typography.Text type="secondary">{requireAuthForPublicAIHubProperty.description}</Typography.Text>
)}
</Space>
</Space>
<Space align="start" size="middle">
<Switch
<SettingRow
checked={Boolean(values.forward_client_headers_to_llm_api)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleForwardClientHeaders}
aria-label={forwardClientHeadersProperty?.description ?? "Forward client headers to LLM API"}
onCheckedChange={handleToggleForwardClientHeaders}
ariaLabel={forwardClientHeadersProperty?.description ?? "Forward client headers to LLM API"}
label="Forward client headers to LLM API"
description={
forwardClientHeadersProperty?.description ??
"Forwards client headers (Authorization, anthropic-beta, and x-* custom headers) to the upstream LLM. Enable for Claude Code with a Max subscription (forwards the OAuth token) or to pass custom/tracing headers through to the provider. Independent of the BYOK toggle — enable only the one(s) you need."
}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Forward client headers to LLM API</Typography.Text>
<Typography.Text type="secondary">
{forwardClientHeadersProperty?.description ??
"Forwards client headers (Authorization, anthropic-beta, and x-* custom headers) to the upstream LLM. Enable for Claude Code with a Max subscription (forwards the OAuth token) or to pass custom/tracing headers through to the provider. Independent of the BYOK toggle — enable only the one(s) you need."}
</Typography.Text>
</Space>
</Space>
<Space align="start" size="middle">
<Switch
<SettingRow
checked={Boolean(values.forward_llm_provider_auth_headers)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleForwardLLMProviderAuthHeaders}
aria-label={forwardLLMProviderAuthHeadersProperty?.description ?? "Forward LLM provider auth headers"}
onCheckedChange={handleToggleForwardLLMProviderAuthHeaders}
ariaLabel={forwardLLMProviderAuthHeadersProperty?.description ?? "Forward LLM provider auth headers"}
label="Forward LLM provider auth headers"
description={
forwardLLMProviderAuthHeadersProperty?.description ??
"Forwards provider auth headers (x-api-key, x-goog-api-key, api-key, ocp-apim-subscription-key) to the upstream LLM, overriding any deployment-configured key for that request. Enable for Claude Code BYOK (clients bring their own API key). Independent of the client-headers toggle — enable only the one(s) you need."
}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Forward LLM provider auth headers</Typography.Text>
<Typography.Text type="secondary">
{forwardLLMProviderAuthHeadersProperty?.description ??
"Forwards provider auth headers (x-api-key, x-goog-api-key, api-key, ocp-apim-subscription-key) to the upstream LLM, overriding any deployment-configured key for that request. Enable for Claude Code BYOK (clients bring their own API key). Independent of the client-headers toggle — enable only the one(s) you need."}
</Typography.Text>
</Space>
</Space>
{enableProjectsUIProperty && (
<Space align="start" size="middle">
<Switch
{enableProjectsUIProperty && (
<SettingRow
checked={Boolean(values.enable_projects_ui)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleEnableProjectsUI}
aria-label={enableProjectsUIProperty.description ?? "Enable Projects UI"}
onCheckedChange={handleToggleEnableProjectsUI}
ariaLabel={enableProjectsUIProperty.description ?? "Enable Projects UI"}
label="[BETA] Enable Projects (page will refresh)"
description={
enableProjectsUIProperty.description ??
"If enabled, shows the Projects feature in the UI sidebar and the project field in key management."
}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>[BETA] Enable Projects (page will refresh)</Typography.Text>
<Typography.Text type="secondary">
{enableProjectsUIProperty.description ??
"If enabled, shows the Projects feature in the UI sidebar and the project field in key management."}
</Typography.Text>
</Space>
</Space>
)}
<Space align="start" size="middle">
<Switch
)}
<SettingRow
checked={Boolean(values.enable_chat_ui)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleEnableChatUI}
aria-label={enableChatUIProperty?.description ?? "Enable Chat page"}
onCheckedChange={handleToggleEnableChatUI}
ariaLabel={enableChatUIProperty?.description ?? "Enable Chat page"}
label="[BETA] Enable Chat page (page will refresh)"
description={
enableChatUIProperty?.description ??
"If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."
}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>[BETA] Enable Chat page (page will refresh)</Typography.Text>
<Typography.Text type="secondary">
{enableChatUIProperty?.description ??
"If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Agents access control */}
<Space align="start" size="middle">
<Switch
<Separator />
<SettingRow
checked={isAgentsDisabled}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleDisableAgents}
aria-label={disableAgentsProperty?.description ?? "Disable agents for internal users"}
onCheckedChange={handleToggleDisableAgents}
ariaLabel={disableAgentsProperty?.description ?? "Disable agents for internal users"}
label="Disable agents for internal users"
description={disableAgentsProperty?.description}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Disable agents for internal users</Typography.Text>
{disableAgentsProperty?.description && (
<Typography.Text type="secondary">{disableAgentsProperty.description}</Typography.Text>
)}
</Space>
</Space>
<Space align="start" size="middle" style={{ marginLeft: 32 }}>
<Switch
<SettingRow
checked={Boolean(values.allow_agents_for_team_admins)}
disabled={isUpdating || !isAgentsDisabled}
loading={isUpdating}
onChange={handleToggleAllowAgentsTeamAdmins}
aria-label={allowAgentsTeamAdminsProperty?.description ?? "Allow agents for team admins"}
onCheckedChange={handleToggleAllowAgentsTeamAdmins}
ariaLabel={allowAgentsTeamAdminsProperty?.description ?? "Allow agents for team admins"}
label="Allow agents for team admins"
description={allowAgentsTeamAdminsProperty?.description}
indented
muted={!isAgentsDisabled}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong type={!isAgentsDisabled ? "secondary" : undefined}>
Allow agents for team admins
</Typography.Text>
{allowAgentsTeamAdminsProperty?.description && (
<Typography.Text type="secondary">{allowAgentsTeamAdminsProperty.description}</Typography.Text>
)}
</Space>
</Space>
<Divider />
{/* Vector Stores access control */}
<Space align="start" size="middle">
<Switch
<Separator />
<SettingRow
checked={isVectorStoresDisabled}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleDisableVectorStores}
aria-label={disableVectorStoresProperty?.description ?? "Disable vector stores for internal users"}
onCheckedChange={handleToggleDisableVectorStores}
ariaLabel={disableVectorStoresProperty?.description ?? "Disable vector stores for internal users"}
label="Disable vector stores for internal users"
description={disableVectorStoresProperty?.description}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Disable vector stores for internal users</Typography.Text>
{disableVectorStoresProperty?.description && (
<Typography.Text type="secondary">{disableVectorStoresProperty.description}</Typography.Text>
)}
</Space>
</Space>
<Space align="start" size="middle" style={{ marginLeft: 32 }}>
<Switch
<SettingRow
checked={Boolean(values.allow_vector_stores_for_team_admins)}
disabled={isUpdating || !isVectorStoresDisabled}
loading={isUpdating}
onChange={handleToggleAllowVectorStoresTeamAdmins}
aria-label={allowVectorStoresTeamAdminsProperty?.description ?? "Allow vector stores for team admins"}
onCheckedChange={handleToggleAllowVectorStoresTeamAdmins}
ariaLabel={allowVectorStoresTeamAdminsProperty?.description ?? "Allow vector stores for team admins"}
label="Allow vector stores for team admins"
description={allowVectorStoresTeamAdminsProperty?.description}
indented
muted={!isVectorStoresDisabled}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong type={!isVectorStoresDisabled ? "secondary" : undefined}>
Allow vector stores for team admins
</Typography.Text>
{allowVectorStoresTeamAdminsProperty?.description && (
<Typography.Text type="secondary">{allowVectorStoresTeamAdminsProperty.description}</Typography.Text>
)}
</Space>
</Space>
<Divider />
{/* Scope user search to organization */}
<Space align="start" size="middle">
<Switch
<Separator />
<SettingRow
checked={Boolean(values.scope_user_search_to_org)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleScopeUserSearch}
aria-label={scopeUserSearchProperty?.description ?? "Scope user search to organization"}
onCheckedChange={handleToggleScopeUserSearch}
ariaLabel={scopeUserSearchProperty?.description ?? "Scope user search to organization"}
label="Scope user search to organization"
description={
scopeUserSearchProperty?.description ??
"If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users."
}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Scope user search to organization</Typography.Text>
<Typography.Text type="secondary">
{scopeUserSearchProperty?.description ??
"If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Disable custom Virtual key values */}
<Space align="start" size="middle">
<Switch
<Separator />
<SettingRow
checked={Boolean(values.disable_custom_api_keys)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleDisableCustomApiKeys}
aria-label={disableCustomApiKeysProperty?.description ?? "Disable custom Virtual key values"}
onCheckedChange={handleToggleDisableCustomApiKeys}
ariaLabel={disableCustomApiKeysProperty?.description ?? "Disable custom Virtual key values"}
label="Disable custom Virtual key values"
description={
disableCustomApiKeysProperty?.description ??
"If true, users cannot specify custom key values. All keys must be auto-generated."
}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Disable custom Virtual key values</Typography.Text>
<Typography.Text type="secondary">
{disableCustomApiKeysProperty?.description ??
"If true, users cannot specify custom key values. All keys must be auto-generated."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Page Visibility for Internal Users */}
<PageVisibilitySettings
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}
enabledPagesPropertyDescription={enabledPagesProperty?.description}
isUpdating={isUpdating}
onUpdate={handleUpdatePageVisibility}
/>
</Space>
)}
<Separator />
<PageVisibilitySettings
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}
enabledPagesPropertyDescription={enabledPagesProperty?.description}
isUpdating={isUpdating}
onUpdate={handleUpdatePageVisibility}
/>
</div>
)}
</CardContent>
</Card>
);
}