mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
7d12f21e31
commit
4445eb71f6
13 changed files with 669 additions and 927 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 "key"</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 : ""}?`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue