feat(ui): migrate HashicorpVault admin-settings subtree to shadcn

- HashicorpVaultEmptyPlaceholder: antd Empty/Typography/Button \u2192 plain
  Tailwind empty-state with shadcn Button + lucide Inbox icon.
- EditHashicorpVaultModal: antd Modal/Form/Input/Input.Password/Divider/
  Typography \u2192 shadcn Dialog + react-hook-form (mode='onSubmit') +
  Input/Label/Separator + lucide Eye/EyeOff for sensitive-field toggles.
  Sensitive-field placeholders preserve the existing-value hint pattern.
- HashicorpVault: antd Alert/Card/Descriptions/Flex/Skeleton/Space/
  Typography \u2192 shadcn Alert/Card/Skeleton + plain Tailwind <dl> grid +
  lucide icons. Two DeleteResourceModal call sites preserved (whole-config
  delete and per-field clear). Auth-method detection helper unchanged.

Gates: TS \u2713 | Vitest 3/3 \u2713 (HashicorpVaultEmptyPlaceholder.test.tsx
existing) | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 08:02:48 +00:00
parent 4d82b4a84a
commit de34bbda12
No known key found for this signature in database
3 changed files with 291 additions and 180 deletions

View file

@ -4,8 +4,21 @@ import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides
import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import NotificationManager from "@/components/molecules/notifications_manager";
import { Button, Divider, Form, Input, Modal, Space, Typography } from "antd";
import React, { useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Eye, EyeOff } from "lucide-react";
import React, { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
interface FieldGroup {
@ -17,16 +30,23 @@ interface FieldGroup {
const FIELD_GROUPS: FieldGroup[] = [
{
title: "Connection",
fields: ["vault_addr", "vault_namespace", "vault_mount_name", "vault_path_prefix"],
fields: [
"vault_addr",
"vault_namespace",
"vault_mount_name",
"vault_path_prefix",
],
},
{
title: "Token Authentication",
subtitle: "Use a Vault token to authenticate. Only one auth method is required.",
subtitle:
"Use a Vault token to authenticate. Only one auth method is required.",
fields: ["vault_token"],
},
{
title: "AppRole Authentication",
subtitle: "Use AppRole credentials to authenticate. Only one auth method is required.",
subtitle:
"Use AppRole credentials to authenticate. Only one auth method is required.",
fields: ["approle_role_id", "approle_secret_id", "approle_mount_path"],
},
{
@ -42,60 +62,64 @@ interface EditHashicorpVaultModalProps {
onSuccess: () => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FormValues = Record<string, any>;
const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({
isVisible,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const { accessToken } = useAuthorized();
const { data } = useHashicorpVaultConfig();
const { mutate, isPending } = useUpdateHashicorpVaultConfig(accessToken);
const form = useForm<FormValues>({ mode: "onSubmit" });
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
{},
);
const schema = data?.field_schema;
const properties = schema?.properties ?? {};
const rawValues = data?.values ?? {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema: any = data?.field_schema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const properties: Record<string, any> = schema?.properties ?? {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawValues: Record<string, any> = data?.values ?? {};
useEffect(() => {
if (isVisible && data) {
form.resetFields();
// Only set non-sensitive fields — sensitive ones show as placeholders
const formValues: Record<string, any> = {};
const formValues: FormValues = {};
for (const [key, value] of Object.entries(rawValues)) {
if (!SENSITIVE_FIELDS.has(key)) {
formValues[key] = value;
}
if (!SENSITIVE_FIELDS.has(key)) formValues[key] = value;
}
form.setFieldsValue(formValues);
form.reset(formValues);
}
}, [isVisible, data, form]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible, data]);
const handleSubmit = (formValues: Record<string, any>) => {
const config: Record<string, any> = {};
const handleSubmit = form.handleSubmit((formValues) => {
const config: FormValues = {};
for (const [key, value] of Object.entries(formValues)) {
if (value !== undefined && value !== null && value !== "") {
// Non-empty value → update
config[key] = value;
} else if (!SENSITIVE_FIELDS.has(key)) {
// Non-sensitive field cleared → send "" to clear it on the backend
config[key] = "";
}
// Sensitive field left blank → omit from payload (keep existing)
}
mutate(config, {
onSuccess: () => {
NotificationManager.success("Hashicorp Vault configuration updated successfully");
NotificationManager.success(
"Hashicorp Vault configuration updated successfully",
);
onSuccess();
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
});
};
});
const handleCancel = () => {
form.resetFields();
form.reset();
onCancel();
};
@ -103,68 +127,118 @@ const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({
const fieldSchema = properties[fieldName];
if (!fieldSchema) return null;
const rules =
fieldName === "vault_addr"
? [{ pattern: /^https?:\/\/.+/, message: "Must start with http:// or https://" }]
: undefined;
const isSensitive = SENSITIVE_FIELDS.has(fieldName);
const existingValue = rawValues[fieldName];
const hasExistingValue = isSensitive && existingValue != null && existingValue !== "";
const hasExistingValue =
isSensitive && existingValue != null && existingValue !== "";
const placeholder = hasExistingValue
? `Leave blank to keep existing (${existingValue})`
: fieldSchema?.description;
const validators =
fieldName === "vault_addr"
? {
pattern: {
value: /^https?:\/\/.+/,
message: "Must start with http:// or https://",
},
}
: {};
return (
<Form.Item
key={fieldName}
name={fieldName}
label={FIELD_LABELS[fieldName] ?? fieldName}
rules={rules}
>
<div key={fieldName} className="space-y-2 mb-4">
<Label htmlFor={fieldName}>
{FIELD_LABELS[fieldName] ?? fieldName}
</Label>
{isSensitive ? (
<Input.Password placeholder={placeholder} />
<div className="relative">
<Input
id={fieldName}
type={showSensitive[fieldName] ? "text" : "password"}
placeholder={placeholder}
{...form.register(fieldName, validators)}
/>
<button
type="button"
onClick={() =>
setShowSensitive((prev) => ({
...prev,
[fieldName]: !prev[fieldName],
}))
}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-label={
showSensitive[fieldName] ? "Hide value" : "Show value"
}
>
{showSensitive[fieldName] ? (
<EyeOff size={14} />
) : (
<Eye size={14} />
)}
</button>
</div>
) : (
<Input placeholder={fieldSchema?.description} />
<Input
id={fieldName}
placeholder={fieldSchema?.description}
{...form.register(fieldName, validators)}
/>
)}
</Form.Item>
{form.formState.errors[fieldName] && (
<p className="text-sm text-destructive">
{form.formState.errors[fieldName]?.message as string}
</p>
)}
</div>
);
};
return (
<Modal
title="Edit Hashicorp Vault Configuration"
<Dialog
open={isVisible}
width={700}
footer={
<Space>
<Button onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button type="primary" loading={isPending} onClick={() => form.submit()}>
{isPending ? "Saving..." : "Save"}
</Button>
</Space>
}
onCancel={handleCancel}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{FIELD_GROUPS.map((group, index) => (
<div key={group.title}>
{index > 0 && <Divider />}
<Typography.Title level={5} style={{ marginBottom: 4 }}>
{group.title}
</Typography.Title>
{group.subtitle && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
{group.subtitle}
</Typography.Paragraph>
)}
{group.fields.map(renderField)}
</div>
))}
</Form>
</Modal>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Hashicorp Vault Configuration</DialogTitle>
<DialogDescription className="sr-only">
Configure Hashicorp Vault credentials and connection details.
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={handleSubmit}>
{FIELD_GROUPS.map((group, index) => (
<div key={group.title}>
{index > 0 && <Separator className="my-4" />}
<h5 className="text-base font-semibold mb-1">
{group.title}
</h5>
{group.subtitle && (
<p className="text-sm text-muted-foreground mb-4">
{group.subtitle}
</p>
)}
{group.fields.map(renderField)}
</div>
))}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isPending}
>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};

View file

@ -8,14 +8,20 @@ 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 {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Edit, KeyRound, PlugZap, Trash2 } from "lucide-react";
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
import EditHashicorpVaultModal from "./EditHashicorpVaultModal";
import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder";
const { Title, Text } = Typography;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function detectAuthMethod(values: Record<string, any>): string {
if (values.approle_role_id || values.approle_secret_id) return "AppRole";
if (values.client_cert && values.client_key) return "TLS Certificate";
@ -23,22 +29,21 @@ function detectAuthMethod(values: Record<string, any>): string {
return "None";
}
const descriptionsConfig = {
column: { xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
};
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 { 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 ?? {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawValues: Record<string, any> = data?.values ?? {};
const isConfigured = Boolean(rawValues.vault_addr);
const handleTestConnection = async () => {
@ -46,7 +51,9 @@ export default function HashicorpVault() {
setIsTesting(true);
try {
const result = await testHashicorpVaultConnection(accessToken);
NotificationManager.success(result.message || "Connection to Vault successful!");
NotificationManager.success(
result.message || "Connection to Vault successful!",
);
} catch (err) {
NotificationManager.fromBackend(err);
} finally {
@ -57,7 +64,9 @@ export default function HashicorpVault() {
const handleDelete = () => {
deleteConfig(undefined, {
onSuccess: () => {
NotificationManager.success("Hashicorp Vault configuration deleted");
NotificationManager.success(
"Hashicorp Vault configuration deleted",
);
setIsDeleteModalOpen(false);
},
onError: (err) => {
@ -68,142 +77,167 @@ export default function HashicorpVault() {
const handleClearField = () => {
if (!clearingField) return;
updateConfig({ [clearingField]: "" }, {
onSuccess: () => {
NotificationManager.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`);
setClearingField(null);
updateConfig(
{ [clearingField]: "" },
{
onSuccess: () => {
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>;
return (
<span className="text-muted-foreground 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>
<div className="flex justify-between items-center">
<span className="font-mono text-muted-foreground">{value}</span>
<Button
type="text"
size="small"
danger
icon={<Trash2 className="w-3.5 h-3.5" />}
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setClearingField(key)}
/>
</Flex>
aria-label={`Clear ${FIELD_LABELS[key] ?? key}`}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
);
}
return <Text className="font-mono text-gray-600">{value}</Text>;
return <span className="font-mono text-muted-foreground">{value}</span>;
};
const renderSettings = () => {
// Only show fields that have values, plus auth method
const fieldsToShow = Object.entries(rawValues).filter(
([_, value]) => value != null && value !== ""
([, value]) => value != null && value !== "",
);
if (fieldsToShow.length === 0) return null;
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>
<dl className="border border-border rounded-md overflow-hidden text-sm">
<div className="grid grid-cols-[200px_1fr] border-b border-border">
<dt className="bg-muted px-4 py-3 font-medium">Auth Method</dt>
<dd className="px-4 py-3">{detectAuthMethod(rawValues)}</dd>
</div>
{fieldsToShow.map(([key], i) => (
<div
key={key}
className={`grid grid-cols-[200px_1fr] ${
i < fieldsToShow.length - 1 ? "border-b border-border" : ""
}`}
>
<dt className="bg-muted px-4 py-3 font-medium">
{FIELD_LABELS[key] ?? key}
</dt>
<dd className="px-4 py-3">{renderValue(key)}</dd>
</div>
))}
</Descriptions>
</dl>
);
};
return (
<>
{isLoading ? (
<Card>
<Skeleton active />
<Card className="p-6">
<Skeleton className="h-32 w-full" />
</Card>
) : isError ? (
<Card>
<Alert
type="error"
message="Could not load Hashicorp Vault configuration"
description={error instanceof Error ? error.message : undefined}
/>
<Card className="p-6">
<Alert variant="destructive">
<AlertTitle>Could not load Hashicorp Vault configuration</AlertTitle>
{error instanceof Error && (
<AlertDescription>{error.message}</AlertDescription>
)}
</Alert>
</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" />
<Card className="p-6">
<div className="space-y-6 w-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<KeyRound className="w-6 h-6 text-muted-foreground" />
<div>
<Title level={3} style={{ marginBottom: 0 }}>Hashicorp Vault</Title>
<Text type="secondary">Manage secret manager configuration</Text>
<h3 className="text-lg font-semibold m-0">
Hashicorp Vault
</h3>
<p className="text-sm text-muted-foreground">
Manage secret manager configuration
</p>
</div>
</Flex>
</div>
<Space>
<div className="flex gap-2">
{isConfigured && (
<>
<Button
icon={<PlugZap className="w-4 h-4" />}
loading={isTesting}
variant="outline"
onClick={handleTestConnection}
disabled={isTesting}
>
Test Connection
<PlugZap className="w-4 h-4" />
{isTesting ? "Testing…" : "Test Connection"}
</Button>
<Button
icon={<Edit className="w-4 h-4" />}
variant="outline"
onClick={() => setIsEditModalVisible(true)}
>
<Edit className="w-4 h-4" />
Edit Configuration
</Button>
<Button
danger
icon={<Trash2 className="w-4 h-4" />}
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
>
<Trash2 className="w-4 h-4" />
Delete Configuration
</Button>
</>
)}
</Space>
</Flex>
</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>
</>
}
/>
<Alert>
<AlertTitle>
Secrets must be stored with the field name &quot;key&quot;
</AlertTitle>
<AlertDescription>
<code className="bg-muted px-1 py-0.5 rounded text-xs">
vault kv put secret/SECRET_NAME key=secret_value
</code>
<br />
<a
href="https://docs.litellm.ai/docs/secret_managers/hashicorp_vault"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
View documentation
</a>
</AlertDescription>
</Alert>
)}
{isConfigured ? (
renderSettings()
) : (
<HashicorpVaultEmptyPlaceholder onAdd={() => setIsEditModalVisible(true)} />
<HashicorpVaultEmptyPlaceholder
onAdd={() => setIsEditModalVisible(true)}
/>
)}
</Space>
</div>
</Card>
)}
@ -232,7 +266,12 @@ export default function HashicorpVault() {
message="This will remove the stored value."
resourceInformationTitle="Field"
resourceInformation={[
{ label: "Field", value: clearingField ? (FIELD_LABELS[clearingField] ?? clearingField) : "" },
{
label: "Field",
value: clearingField
? (FIELD_LABELS[clearingField] ?? clearingField)
: "",
},
]}
onCancel={() => setClearingField(null)}
onOk={handleClearField}

View file

@ -1,30 +1,28 @@
import { Empty, Typography, Button } from "antd";
const { Title, Paragraph } = Typography;
import { Button } from "@/components/ui/button";
import { Inbox } from "lucide-react";
interface HashicorpVaultEmptyPlaceholderProps {
onAdd: () => void;
}
export default function HashicorpVaultEmptyPlaceholder({ onAdd }: 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">
<div className="bg-background p-12 rounded-lg border border-dashed border-border text-center w-full">
<div className="flex flex-col items-center gap-3">
<Inbox className="h-12 w-12 text-muted-foreground" />
<h4 className="text-lg font-semibold">
No Vault Configuration Found
</h4>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Configure Hashicorp Vault to securely manage provider API keys and
secrets for your LiteLLM deployment.
</p>
<Button onClick={onAdd} className="mt-4">
Configure Vault
</Button>
</Empty>
</div>
</div>
);
}