diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig.ts new file mode 100644 index 00000000000..365ee7bf9a9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig.ts @@ -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 }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig.ts new file mode 100644 index 00000000000..62b6d8cfab4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig.ts @@ -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>({ + 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, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig.ts new file mode 100644 index 00000000000..cea447c4908 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig.ts @@ -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) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return updateHashicorpVaultConfig(accessToken, config); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: hashicorpVaultKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx new file mode 100644 index 00000000000..314c5456ab0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx @@ -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 = ({ + 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 = {}; + 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) => { + const config: Record = {}; + 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 ( + + {isSensitive ? ( + + ) : ( + + )} + + ); + }; + + return ( + + + + + } + onCancel={handleCancel} + > +
+ {FIELD_GROUPS.map((group, index) => ( +
+ {index > 0 && } + + {group.title} + + {group.subtitle && ( + + {group.subtitle} + + )} + {group.fields.map(renderField)} +
+ ))} +
+
+ ); +}; + +export default EditHashicorpVaultModal; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx new file mode 100644 index 00000000000..a2693903c52 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx @@ -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 { + 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(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 Not configured; + } + if (SENSITIVE_FIELDS.has(key)) { + return ( +
+ {value} +
+ ); + } + return {value}; + }; + + 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 ( + + + {detectAuthMethod(rawValues)} + + {fieldsToShow.map(([key]) => ( + + {renderValue(key)} + + ))} + + ); + }; + + return ( + <> + {isLoading ? ( + + + + ) : isError ? ( + + + + ) : ( + + + + {/* Header */} +
+
+ +
+ Hashicorp Vault + Manage secret manager configuration +
+
+ +
+ {isConfigured && ( + <> + + + + + )} +
+
+ + {isConfigured && ( + + vault kv put secret/SECRET_NAME key=secret_value +
+ + View documentation + + + } + /> + )} + + {isConfigured ? ( + renderSettings() + ) : ( + setIsEditModalVisible(true)} /> + )} +
+
+
+ )} + + setIsEditModalVisible(false)} + onSuccess={() => { + setIsEditModalVisible(false); + refetch(); + }} + /> + + setIsDeleteModalOpen(false)} + onOk={handleDelete} + confirmLoading={isDeleting} + /> + + setClearingField(null)} + onOk={handleClearField} + confirmLoading={isClearingField} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx new file mode 100644 index 00000000000..49860fc7617 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx @@ -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 ( +
+ + No Vault Configuration Found + + Configure Hashicorp Vault to securely manage provider API keys and secrets + for your LiteLLM deployment. + +
+ } + > + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/constants.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/constants.ts new file mode 100644 index 00000000000..ef924f5f122 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/constants.ts @@ -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 = { + 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", +}; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0df6d813d7c..a8f6013726c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -9659,6 +9659,95 @@ export const updateUiSettings = async (accessToken: string, settings: Record { + 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, +) => { + 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 // ============================================================