From c98da10b842062fcba669e305bc84e5832a0e71c Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 5 Mar 2026 16:34:25 -0800 Subject: [PATCH] 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. --- .../useDeleteHashicorpVaultConfig.ts | 21 ++ .../useHashicorpVaultConfig.ts | 23 ++ .../useUpdateHashicorpVaultConfig.ts | 21 ++ .../EditHashicorpVaultModal.tsx | 171 ++++++++++++ .../HashicorpVault/HashicorpVault.tsx | 250 ++++++++++++++++++ .../HashicorpVaultEmptyPlaceholder.tsx | 30 +++ .../AdminSettings/HashicorpVault/constants.ts | 20 ++ .../src/components/networking.tsx | 89 +++++++ 8 files changed, 625 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig.ts create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/constants.ts 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 // ============================================================