diff --git a/client/package.json b/client/package.json index fd6fc6980..db4358fdd 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", @@ -69,6 +70,7 @@ "globals": "^15.9.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fixed-jsdom": "^0.0.9", "postcss": "^8.5.6", "tailwindcss": "^3.4.13", "tailwindcss-animate": "^1.0.7", diff --git a/client/src/App.tsx b/client/src/App.tsx index 60ce90c51..4c35b0ec1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -74,6 +74,10 @@ import ElicitationTab, { PendingElicitationRequest, ElicitationResponse, } from "./components/ElicitationTab"; +import { + CustomHeaders, + migrateFromLegacyAuth, +} from "./lib/types/customHeaders"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -127,6 +131,39 @@ const App = () => { return localStorage.getItem("lastOauthScope") || ""; }); + // Custom headers state with migration from legacy auth + const [customHeaders, setCustomHeaders] = useState(() => { + const savedHeaders = localStorage.getItem("lastCustomHeaders"); + if (savedHeaders) { + try { + return JSON.parse(savedHeaders); + } catch (error) { + console.warn( + `Failed to parse custom headers: "${savedHeaders}", will try legacy migration`, + error, + ); + // Fall back to migration if JSON parsing fails + } + } + + // Migrate from legacy auth if available + const legacyToken = localStorage.getItem("lastBearerToken") || ""; + const legacyHeaderName = localStorage.getItem("lastHeaderName") || ""; + + if (legacyToken) { + return migrateFromLegacyAuth(legacyToken, legacyHeaderName); + } + + // Default to Authorization: Bearer as the most common case + return [ + { + name: "Authorization", + value: "Bearer ", + enabled: true, + }, + ]; + }); + const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -213,8 +250,7 @@ const App = () => { args, sseUrl, env, - bearerToken, - headerName, + customHeaders, oauthClientId, oauthScope, config, @@ -303,13 +339,38 @@ const App = () => { }, [transportType]); useEffect(() => { - localStorage.setItem("lastBearerToken", bearerToken); + if (bearerToken) { + localStorage.setItem("lastBearerToken", bearerToken); + } else { + localStorage.removeItem("lastBearerToken"); + } }, [bearerToken]); useEffect(() => { - localStorage.setItem("lastHeaderName", headerName); + if (headerName) { + localStorage.setItem("lastHeaderName", headerName); + } else { + localStorage.removeItem("lastHeaderName"); + } }, [headerName]); + useEffect(() => { + localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders)); + }, [customHeaders]); + + // Auto-migrate from legacy auth when custom headers are empty but legacy auth exists + useEffect(() => { + if (customHeaders.length === 0 && (bearerToken || headerName)) { + const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName); + if (migratedHeaders.length > 0) { + setCustomHeaders(migratedHeaders); + // Clear legacy auth after migration + setBearerToken(""); + setHeaderName(""); + } + } + }, [bearerToken, headerName, customHeaders, setCustomHeaders]); + useEffect(() => { localStorage.setItem("lastOauthClientId", oauthClientId); }, [oauthClientId]); @@ -810,10 +871,8 @@ const App = () => { setEnv={setEnv} config={config} setConfig={setConfig} - bearerToken={bearerToken} - setBearerToken={setBearerToken} - headerName={headerName} - setHeaderName={setHeaderName} + customHeaders={customHeaders} + setCustomHeaders={setCustomHeaders} oauthClientId={oauthClientId} setOauthClientId={setOauthClientId} oauthScope={oauthScope} diff --git a/client/src/components/CustomHeaders.tsx b/client/src/components/CustomHeaders.tsx new file mode 100644 index 000000000..463f7333b --- /dev/null +++ b/client/src/components/CustomHeaders.tsx @@ -0,0 +1,241 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Plus, Trash2, Eye, EyeOff } from "lucide-react"; +import { + CustomHeaders as CustomHeadersType, + CustomHeader, + createEmptyHeader, +} from "@/lib/types/customHeaders"; + +interface CustomHeadersProps { + headers: CustomHeadersType; + onChange: (headers: CustomHeadersType) => void; + className?: string; +} + +const CustomHeaders = ({ + headers, + onChange, + className, +}: CustomHeadersProps) => { + const [isJsonMode, setIsJsonMode] = useState(false); + const [jsonValue, setJsonValue] = useState(""); + const [jsonError, setJsonError] = useState(null); + const [visibleValues, setVisibleValues] = useState>(new Set()); + + const updateHeader = ( + index: number, + field: keyof CustomHeader, + value: string | boolean, + ) => { + const newHeaders = [...headers]; + newHeaders[index] = { ...newHeaders[index], [field]: value }; + onChange(newHeaders); + }; + + const addHeader = () => { + onChange([...headers, createEmptyHeader()]); + }; + + const removeHeader = (index: number) => { + const newHeaders = headers.filter((_, i) => i !== index); + onChange(newHeaders); + }; + + const toggleValueVisibility = (index: number) => { + const newVisible = new Set(visibleValues); + if (newVisible.has(index)) { + newVisible.delete(index); + } else { + newVisible.add(index); + } + setVisibleValues(newVisible); + }; + + const switchToJsonMode = () => { + const jsonObject: Record = {}; + headers.forEach((header) => { + if (header.enabled && header.name.trim() && header.value.trim()) { + jsonObject[header.name.trim()] = header.value.trim(); + } + }); + setJsonValue(JSON.stringify(jsonObject, null, 2)); + setJsonError(null); + setIsJsonMode(true); + }; + + const switchToFormMode = () => { + try { + const parsed = JSON.parse(jsonValue); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + setJsonError("JSON must be an object with string key-value pairs"); + return; + } + + const newHeaders: CustomHeadersType = Object.entries(parsed).map( + ([name, value]) => ({ + name, + value: String(value), + enabled: true, + }), + ); + + onChange(newHeaders); + setJsonError(null); + setIsJsonMode(false); + } catch { + setJsonError("Invalid JSON format"); + } + }; + + const handleJsonChange = (value: string) => { + setJsonValue(value); + setJsonError(null); + }; + + if (isJsonMode) { + return ( +
+
+

+ Custom Headers (JSON) +

+ +
+
+