Skip to content

Commit c5acc63

Browse files
Add support for custom headers and migrate from legacy auth
- Introduced CustomHeaders component for managing custom headers. - Updated Sidebar to include CustomHeaders component. - Enhanced useConnection hook to handle custom headers and legacy auth migration. - Added tests for custom headers functionality. - Updated package.json and package-lock.json for new dependencies.
1 parent b476ece commit c5acc63

File tree

12 files changed

+606
-48
lines changed

12 files changed

+606
-48
lines changed

client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@radix-ui/react-popover": "^1.1.3",
3434
"@radix-ui/react-select": "^2.1.2",
3535
"@radix-ui/react-slot": "^1.1.0",
36+
"@radix-ui/react-switch": "^1.2.6",
3637
"@radix-ui/react-tabs": "^1.1.1",
3738
"@radix-ui/react-toast": "^1.2.6",
3839
"@radix-ui/react-tooltip": "^1.1.8",
@@ -69,6 +70,7 @@
6970
"globals": "^15.9.0",
7071
"jest": "^29.7.0",
7172
"jest-environment-jsdom": "^29.7.0",
73+
"jest-fixed-jsdom": "^0.0.9",
7274
"postcss": "^8.5.6",
7375
"tailwindcss": "^3.4.13",
7476
"tailwindcss-animate": "^1.0.7",

client/src/App.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import ElicitationTab, {
7474
PendingElicitationRequest,
7575
ElicitationResponse,
7676
} from "./components/ElicitationTab";
77+
import { CustomHeaders, migrateFromLegacyAuth } from "./lib/types/customHeaders";
7778

7879
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
7980

@@ -127,6 +128,28 @@ const App = () => {
127128
return localStorage.getItem("lastOauthScope") || "";
128129
});
129130

131+
// Custom headers state with migration from legacy auth
132+
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
133+
const savedHeaders = localStorage.getItem("lastCustomHeaders");
134+
if (savedHeaders) {
135+
try {
136+
return JSON.parse(savedHeaders);
137+
} catch {
138+
// Fall back to migration if JSON parsing fails
139+
}
140+
}
141+
142+
// Migrate from legacy auth if available
143+
const legacyToken = localStorage.getItem("lastBearerToken") || "";
144+
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
145+
146+
if (legacyToken) {
147+
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
148+
}
149+
150+
return [];
151+
});
152+
130153
const [pendingSampleRequests, setPendingSampleRequests] = useState<
131154
Array<
132155
PendingRequest & {
@@ -215,6 +238,7 @@ const App = () => {
215238
env,
216239
bearerToken,
217240
headerName,
241+
customHeaders,
218242
oauthClientId,
219243
oauthScope,
220244
config,
@@ -310,6 +334,23 @@ const App = () => {
310334
localStorage.setItem("lastHeaderName", headerName);
311335
}, [headerName]);
312336

337+
useEffect(() => {
338+
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
339+
}, [customHeaders]);
340+
341+
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
342+
useEffect(() => {
343+
if (customHeaders.length === 0 && (bearerToken || headerName)) {
344+
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
345+
if (migratedHeaders.length > 0) {
346+
setCustomHeaders(migratedHeaders);
347+
// Clear legacy auth after migration
348+
setBearerToken("");
349+
setHeaderName("");
350+
}
351+
}
352+
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
353+
313354
useEffect(() => {
314355
localStorage.setItem("lastOauthClientId", oauthClientId);
315356
}, [oauthClientId]);
@@ -814,6 +855,8 @@ const App = () => {
814855
setBearerToken={setBearerToken}
815856
headerName={headerName}
816857
setHeaderName={setHeaderName}
858+
customHeaders={customHeaders}
859+
setCustomHeaders={setCustomHeaders}
817860
oauthClientId={oauthClientId}
818861
setOauthClientId={setOauthClientId}
819862
oauthScope={oauthScope}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { useState } from "react";
2+
import { Button } from "@/components/ui/button";
3+
import { Input } from "@/components/ui/input";
4+
import { Textarea } from "@/components/ui/textarea";
5+
import { Switch } from "@/components/ui/switch";
6+
import { Plus, Trash2, Eye, EyeOff } from "lucide-react";
7+
import { CustomHeaders as CustomHeadersType, CustomHeader, createEmptyHeader } from "@/lib/types/customHeaders";
8+
9+
interface CustomHeadersProps {
10+
headers: CustomHeadersType;
11+
onChange: (headers: CustomHeadersType) => void;
12+
className?: string;
13+
}
14+
15+
const CustomHeaders = ({ headers, onChange, className }: CustomHeadersProps) => {
16+
const [isJsonMode, setIsJsonMode] = useState(false);
17+
const [jsonValue, setJsonValue] = useState("");
18+
const [jsonError, setJsonError] = useState<string | null>(null);
19+
const [visibleValues, setVisibleValues] = useState<Set<number>>(new Set());
20+
21+
const updateHeader = (index: number, field: keyof CustomHeader, value: string | boolean) => {
22+
const newHeaders = [...headers];
23+
newHeaders[index] = { ...newHeaders[index], [field]: value };
24+
onChange(newHeaders);
25+
};
26+
27+
const addHeader = () => {
28+
onChange([...headers, createEmptyHeader()]);
29+
};
30+
31+
const removeHeader = (index: number) => {
32+
const newHeaders = headers.filter((_, i) => i !== index);
33+
onChange(newHeaders);
34+
};
35+
36+
const toggleValueVisibility = (index: number) => {
37+
const newVisible = new Set(visibleValues);
38+
if (newVisible.has(index)) {
39+
newVisible.delete(index);
40+
} else {
41+
newVisible.add(index);
42+
}
43+
setVisibleValues(newVisible);
44+
};
45+
46+
const switchToJsonMode = () => {
47+
const jsonObject: Record<string, string> = {};
48+
headers.forEach((header) => {
49+
if (header.enabled && header.name.trim() && header.value.trim()) {
50+
jsonObject[header.name.trim()] = header.value.trim();
51+
}
52+
});
53+
setJsonValue(JSON.stringify(jsonObject, null, 2));
54+
setJsonError(null);
55+
setIsJsonMode(true);
56+
};
57+
58+
const switchToFormMode = () => {
59+
try {
60+
const parsed = JSON.parse(jsonValue);
61+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
62+
setJsonError("JSON must be an object with string key-value pairs");
63+
return;
64+
}
65+
66+
const newHeaders: CustomHeadersType = Object.entries(parsed).map(([name, value]) => ({
67+
name,
68+
value: String(value),
69+
enabled: true,
70+
}));
71+
72+
onChange(newHeaders);
73+
setJsonError(null);
74+
setIsJsonMode(false);
75+
} catch (error) {
76+
setJsonError("Invalid JSON format");
77+
}
78+
};
79+
80+
const handleJsonChange = (value: string) => {
81+
setJsonValue(value);
82+
setJsonError(null);
83+
};
84+
85+
if (isJsonMode) {
86+
return (
87+
<div className={`space-y-3 ${className}`}>
88+
<div className="flex justify-between items-center gap-2">
89+
<h4 className="text-sm font-semibold flex-shrink-0">Custom Headers (JSON)</h4>
90+
<Button
91+
type="button"
92+
variant="outline"
93+
size="sm"
94+
onClick={switchToFormMode}
95+
className="flex-shrink-0"
96+
>
97+
Switch to Form
98+
</Button>
99+
</div>
100+
<div className="space-y-2">
101+
<Textarea
102+
value={jsonValue}
103+
onChange={(e) => handleJsonChange(e.target.value)}
104+
placeholder='{\n "Authorization": "Bearer token123",\n "X-Tenant-ID": "acme-inc",\n "X-Environment": "staging"\n}'
105+
className="font-mono text-sm min-h-[100px] resize-none"
106+
/>
107+
{jsonError && (
108+
<p className="text-sm text-red-600">{jsonError}</p>
109+
)}
110+
<p className="text-xs text-muted-foreground">
111+
Enter headers as a JSON object with string key-value pairs.
112+
</p>
113+
</div>
114+
</div>
115+
);
116+
}
117+
118+
return (
119+
<div className={`space-y-3 ${className}`}>
120+
<div className="flex justify-between items-center gap-2">
121+
<h4 className="text-sm font-semibold flex-shrink-0">Custom Headers</h4>
122+
<div className="flex gap-1 flex-shrink-0">
123+
<Button
124+
type="button"
125+
variant="outline"
126+
size="sm"
127+
onClick={switchToJsonMode}
128+
className="text-xs px-2"
129+
>
130+
JSON
131+
</Button>
132+
<Button
133+
type="button"
134+
variant="outline"
135+
size="sm"
136+
onClick={addHeader}
137+
className="text-xs px-2"
138+
>
139+
<Plus className="w-3 h-3 mr-1" />
140+
Add
141+
</Button>
142+
</div>
143+
</div>
144+
145+
{headers.length === 0 ? (
146+
<div className="text-center py-4 text-muted-foreground">
147+
<p className="text-sm">No custom headers configured</p>
148+
<p className="text-xs mt-1">Click "Add" to get started</p>
149+
</div>
150+
) : (
151+
<div className="space-y-2 max-h-[300px] overflow-y-auto">
152+
{headers.map((header, index) => (
153+
<div
154+
key={index}
155+
className="flex items-start gap-2 p-2 border rounded-md"
156+
>
157+
<Switch
158+
checked={header.enabled}
159+
onCheckedChange={(enabled) => updateHeader(index, "enabled", enabled)}
160+
className="shrink-0 mt-2"
161+
/>
162+
<div className="flex-1 min-w-0 space-y-2">
163+
<Input
164+
placeholder="Header Name"
165+
value={header.name}
166+
onChange={(e) => updateHeader(index, "name", e.target.value)}
167+
className="font-mono text-xs"
168+
/>
169+
<div className="relative">
170+
<Input
171+
placeholder="Header Value"
172+
value={header.value}
173+
onChange={(e) => updateHeader(index, "value", e.target.value)}
174+
type={visibleValues.has(index) ? "text" : "password"}
175+
className="font-mono text-xs pr-8"
176+
/>
177+
<Button
178+
type="button"
179+
variant="ghost"
180+
size="sm"
181+
onClick={() => toggleValueVisibility(index)}
182+
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
183+
>
184+
{visibleValues.has(index) ? (
185+
<EyeOff className="w-3 h-3" />
186+
) : (
187+
<Eye className="w-3 h-3" />
188+
)}
189+
</Button>
190+
</div>
191+
</div>
192+
<Button
193+
type="button"
194+
variant="ghost"
195+
size="sm"
196+
onClick={() => removeHeader(index)}
197+
className="shrink-0 text-red-600 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0 mt-2"
198+
>
199+
<Trash2 className="w-3 h-3" />
200+
</Button>
201+
</div>
202+
))}
203+
</div>
204+
)}
205+
206+
{headers.length > 0 && (
207+
<p className="text-xs text-muted-foreground">
208+
Use the toggle to enable/disable headers. Only enabled headers with both name and value will be sent.
209+
</p>
210+
)}
211+
</div>
212+
);
213+
};
214+
215+
export default CustomHeaders;

client/src/components/Sidebar.tsx

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import {
3737
TooltipTrigger,
3838
TooltipContent,
3939
} from "@/components/ui/tooltip";
40+
import CustomHeaders from "./CustomHeaders";
41+
import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders";
4042
import { useToast } from "../lib/hooks/useToast";
4143

4244
interface SidebarProps {
@@ -51,10 +53,14 @@ interface SidebarProps {
5153
setSseUrl: (url: string) => void;
5254
env: Record<string, string>;
5355
setEnv: (env: Record<string, string>) => void;
56+
// Legacy auth props (for backward compatibility)
5457
bearerToken: string;
5558
setBearerToken: (token: string) => void;
5659
headerName?: string;
5760
setHeaderName?: (name: string) => void;
61+
// New custom headers support
62+
customHeaders: CustomHeadersType;
63+
setCustomHeaders: (headers: CustomHeadersType) => void;
5864
oauthClientId: string;
5965
setOauthClientId: (id: string) => void;
6066
oauthScope: string;
@@ -80,10 +86,12 @@ const Sidebar = ({
8086
setSseUrl,
8187
env,
8288
setEnv,
83-
bearerToken,
84-
setBearerToken,
85-
headerName,
86-
setHeaderName,
89+
bearerToken: _bearerToken,
90+
setBearerToken: _setBearerToken,
91+
headerName: _headerName,
92+
setHeaderName: _setHeaderName,
93+
customHeaders,
94+
setCustomHeaders,
8795
oauthClientId,
8896
setOauthClientId,
8997
oauthScope,
@@ -497,38 +505,12 @@ const Sidebar = ({
497505
</Button>
498506
{showAuthConfig && (
499507
<>
500-
{/* Bearer Token Section */}
501-
<div className="space-y-2 p-3 rounded border">
502-
<h4 className="text-sm font-semibold flex items-center">
503-
API Token Authentication
504-
</h4>
505-
<div className="space-y-2">
506-
<label className="text-sm font-medium">Header Name</label>
507-
<Input
508-
placeholder="Authorization"
509-
onChange={(e) =>
510-
setHeaderName && setHeaderName(e.target.value)
511-
}
512-
data-testid="header-input"
513-
className="font-mono"
514-
value={headerName}
515-
/>
516-
<label
517-
className="text-sm font-medium"
518-
htmlFor="bearer-token-input"
519-
>
520-
Bearer Token
521-
</label>
522-
<Input
523-
id="bearer-token-input"
524-
placeholder="Bearer Token"
525-
value={bearerToken}
526-
onChange={(e) => setBearerToken(e.target.value)}
527-
data-testid="bearer-token-input"
528-
className="font-mono"
529-
type="password"
530-
/>
531-
</div>
508+
{/* Custom Headers Section */}
509+
<div className="p-3 rounded border overflow-hidden">
510+
<CustomHeaders
511+
headers={customHeaders}
512+
onChange={setCustomHeaders}
513+
/>
532514
</div>
533515
{transportType !== "stdio" && (
534516
// OAuth Configuration

0 commit comments

Comments
 (0)