feat: Hashicorp Vault config UI components

Add settings panel, edit modal, and delete flow for managing Hashicorp
Vault configuration from the admin UI. Extract shared constants, use
React Query hooks, and align error handling with deriveErrorMessage
pattern.
This commit is contained in:
Ryan Crabbe 2026-03-05 16:34:25 -08:00
parent 0f4771fe19
commit c98da10b84
8 changed files with 625 additions and 0 deletions

View file

@ -0,0 +1,21 @@
import { deleteHashicorpVaultConfig } from "@/components/networking";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
const hashicorpVaultKeys = createQueryKeys("hashicorpVaultConfig");
export const useDeleteHashicorpVaultConfig = (accessToken: string | null) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!accessToken) {
throw new Error("Access token is required");
}
return deleteHashicorpVaultConfig(accessToken);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: hashicorpVaultKeys.all });
},
});
};

View file

@ -0,0 +1,23 @@
import { getHashicorpVaultConfig } from "@/components/networking";
import { useQuery } from "@tanstack/react-query";
import useAuthorized from "../useAuthorized";
import { createQueryKeys } from "../common/queryKeysFactory";
const hashicorpVaultKeys = createQueryKeys("hashicorpVaultConfig");
export const useHashicorpVaultConfig = () => {
const { accessToken } = useAuthorized();
return useQuery<Record<string, any>>({
queryKey: hashicorpVaultKeys.list({}),
queryFn: async () => {
if (!accessToken) {
throw new Error("Access token is required");
}
return getHashicorpVaultConfig(accessToken);
},
enabled: !!accessToken,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
});
};

View file

@ -0,0 +1,21 @@
import { updateHashicorpVaultConfig } from "@/components/networking";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
const hashicorpVaultKeys = createQueryKeys("hashicorpVaultConfig");
export const useUpdateHashicorpVaultConfig = (accessToken: string | null) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (config: Record<string, any>) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return updateHashicorpVaultConfig(accessToken, config);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: hashicorpVaultKeys.all });
},
});
};

View file

@ -0,0 +1,171 @@
"use client";
import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig";
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 { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
interface FieldGroup {
title: string;
subtitle?: string;
fields: string[];
}
const FIELD_GROUPS: FieldGroup[] = [
{
title: "Connection",
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.",
fields: ["vault_token"],
},
{
title: "AppRole Authentication",
subtitle: "Use AppRole credentials to authenticate. Only one auth method is required.",
fields: ["approle_role_id", "approle_secret_id", "approle_mount_path"],
},
{
title: "TLS",
subtitle: "Optional client certificate for mTLS.",
fields: ["client_cert", "client_key", "vault_cert_role"],
},
];
interface EditHashicorpVaultModalProps {
isVisible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({
isVisible,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const { accessToken } = useAuthorized();
const { data } = useHashicorpVaultConfig();
const { mutateAsync, isPending } = useUpdateHashicorpVaultConfig(accessToken);
const schema = data?.field_schema;
const properties = schema?.properties ?? {};
const rawValues = data?.values ?? {};
useEffect(() => {
if (isVisible && data) {
form.resetFields();
// Only set non-sensitive fields — sensitive ones show as placeholders
const formValues: Record<string, any> = {};
for (const [key, value] of Object.entries(rawValues)) {
if (!SENSITIVE_FIELDS.has(key)) {
formValues[key] = value;
}
}
form.setFieldsValue(formValues);
}
}, [isVisible, data, form]);
const handleSubmit = async (formValues: Record<string, any>) => {
const config: Record<string, any> = {};
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)
}
await mutateAsync(config, {
onSuccess: () => {
NotificationManager.success("Hashicorp Vault configuration updated successfully");
onSuccess();
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
});
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const renderField = (fieldName: string) => {
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 placeholder = hasExistingValue
? `Leave blank to keep existing (${existingValue})`
: fieldSchema?.description;
return (
<Form.Item
key={fieldName}
name={fieldName}
label={FIELD_LABELS[fieldName] ?? fieldName}
rules={rules}
>
{isSensitive ? (
<Input.Password placeholder={placeholder} />
) : (
<Input placeholder={fieldSchema?.description} />
)}
</Form.Item>
);
};
return (
<Modal
title="Edit Hashicorp Vault Configuration"
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}
>
<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>
);
};
export default EditHashicorpVaultModal;

View file

@ -0,0 +1,250 @@
"use client";
import { useState } from "react";
import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig";
import { useDeleteHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig";
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 "@/components/networking";
import { Alert, Button, Card, Descriptions, Skeleton, Space, Typography } from "antd";
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;
function detectAuthMethod(values: Record<string, any>): string {
if (values.vault_token) return "Token";
if (values.approle_role_id || values.approle_secret_id) return "AppRole";
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, refetch } = useHashicorpVaultConfig();
const { mutate: deleteConfig, isPending: isDeleting } = useDeleteHashicorpVaultConfig(accessToken);
const { mutateAsync: updateConfig } = useUpdateHashicorpVaultConfig(accessToken);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [clearingField, setClearingField] = useState<string | null>(null);
const [isClearingField, setIsClearingField] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const rawValues = data?.values ?? {};
const isConfigured = Boolean(rawValues.vault_addr);
const handleTestConnection = async () => {
if (!accessToken) return;
setIsTesting(true);
try {
const result = await testHashicorpVaultConnection(accessToken);
NotificationManager.success(result.message || "Connection to Vault successful!");
} catch (err) {
NotificationManager.fromBackend(err);
} finally {
setIsTesting(false);
}
};
const handleDelete = () => {
deleteConfig(undefined, {
onSuccess: () => {
NotificationManager.success("Hashicorp Vault configuration deleted");
setIsDeleteModalOpen(false);
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
});
};
const handleClearField = async () => {
if (!clearingField) return;
setIsClearingField(true);
try {
await updateConfig({ [clearingField]: "" });
NotificationManager.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`);
setClearingField(null);
refetch();
} catch (err) {
NotificationManager.fromBackend(err);
} finally {
setIsClearingField(false);
}
};
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 (
<div className="flex items-center justify-between">
<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)}
/>
</div>
);
}
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;
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>
);
};
return (
<>
{isLoading ? (
<Card>
<Skeleton active />
</Card>
) : isError ? (
<Card>
<Alert
type="error"
message="Could not load Hashicorp Vault configuration"
description={error instanceof Error ? error.message : undefined}
/>
</Card>
) : (
<Space direction="vertical" size="large" className="w-full">
<Card>
<Space direction="vertical" size="large" className="w-full">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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>
</div>
<div className="flex items-center gap-3">
{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>
</>
)}
</div>
</div>
{isConfigured && (
<Alert
type="info"
showIcon
message="Secrets must be stored with the field name &quot;key&quot;"
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>
</>
}
/>
)}
{isConfigured ? (
renderSettings()
) : (
<HashicorpVaultEmptyPlaceholder onAdd={() => setIsEditModalVisible(true)} />
)}
</Space>
</Card>
</Space>
)}
<EditHashicorpVaultModal
isVisible={isEditModalVisible}
onCancel={() => setIsEditModalVisible(false)}
onSuccess={() => {
setIsEditModalVisible(false);
refetch();
}}
/>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Hashicorp Vault Configuration?"
message="Models using Vault secrets will lose access to their API keys until a new configuration is saved."
resourceInformationTitle="Vault Configuration"
resourceInformation={[
{ label: "Vault Address", value: rawValues.vault_addr },
]}
onCancel={() => setIsDeleteModalOpen(false)}
onOk={handleDelete}
confirmLoading={isDeleting}
/>
<DeleteResourceModal
isOpen={clearingField !== null}
title={`Clear ${clearingField ? (FIELD_LABELS[clearingField] ?? clearingField) : ""}?`}
message="This will remove the stored value."
resourceInformationTitle="Field"
resourceInformation={[
{ label: "Field", value: clearingField ? (FIELD_LABELS[clearingField] ?? clearingField) : "" },
]}
onCancel={() => setClearingField(null)}
onOk={handleClearField}
confirmLoading={isClearingField}
/>
</>
);
}

View file

@ -0,0 +1,30 @@
import { Empty, Typography, Button } from "antd";
const { Title, Paragraph } = Typography;
interface HashicorpVaultEmptyPlaceholderProps {
onAdd: () => void;
}
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>
);
}

View file

@ -0,0 +1,20 @@
export const SENSITIVE_FIELDS = new Set([
"vault_token",
"approle_role_id",
"approle_secret_id",
"client_key",
]);
export const FIELD_LABELS: Record<string, string> = {
vault_addr: "Vault Address",
vault_namespace: "Namespace",
vault_mount_name: "KV Mount Name",
vault_path_prefix: "Path Prefix",
vault_token: "Token",
approle_role_id: "Role ID",
approle_secret_id: "Secret ID",
approle_mount_path: "Mount Path",
client_cert: "Client Certificate",
client_key: "Client Key",
vault_cert_role: "Certificate Role",
};

View file

@ -9659,6 +9659,95 @@ export const updateUiSettings = async (accessToken: string, settings: Record<str
return data;
};
export const getHashicorpVaultConfig = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json();
const detail = errorData?.detail;
const errorMessage =
(typeof detail === "object" && detail?.error) ||
(typeof detail === "string" && detail) ||
deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};
export const updateHashicorpVaultConfig = async (
accessToken: string,
config: Record<string, any>,
) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};
export const deleteHashicorpVaultConfig = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "DELETE",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};
export const testHashicorpVaultConnection = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault/test_connection`
: `/config_overrides/hashicorp_vault/test_connection`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};
// ============================================================
// Claude Code Marketplace Networking Functions
// ============================================================