mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-01 08:16:44 +00:00
Headers input working on resource
This commit is contained in:
@@ -1505,5 +1505,8 @@
|
|||||||
"internationaldomaindetected": "International Domain Detected",
|
"internationaldomaindetected": "International Domain Detected",
|
||||||
"willbestoredas": "Will be stored as:",
|
"willbestoredas": "Will be stored as:",
|
||||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider"
|
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||||
}
|
"customHeaders": "Custom Headers",
|
||||||
|
"customHeadersDescription": "Headers new line separated: Header-Name: value",
|
||||||
|
"headersValidationError": "Headers must be in the format: Header-Name: value"
|
||||||
|
}
|
||||||
@@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateHeaders(headers: string): boolean {
|
||||||
|
// Validate comma-separated headers in format "Header-Name: value"
|
||||||
|
const headerPairs = headers.split(",").map((pair) => pair.trim());
|
||||||
|
return headerPairs.every((pair) => {
|
||||||
|
// Check if the pair contains exactly one colon
|
||||||
|
const colonCount = (pair.match(/:/g) || []).length;
|
||||||
|
if (colonCount !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonIndex = pair.indexOf(":");
|
||||||
|
if (colonIndex === 0 || colonIndex === pair.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerName = pair.substring(0, colonIndex).trim();
|
||||||
|
const headerValue = pair.substring(colonIndex + 1).trim();
|
||||||
|
|
||||||
|
// Header name should not be empty and should contain valid characters
|
||||||
|
// Header names are case-insensitive and can contain alphanumeric, hyphens
|
||||||
|
const headerNameRegex = /^[a-zA-Z0-9\-_]+$/;
|
||||||
|
if (!headerName || !headerNameRegex.test(headerName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header value should not be empty and should not contain colons
|
||||||
|
if (!headerValue || headerValue.includes(":")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const validTlds = [
|
const validTlds = [
|
||||||
"AAA",
|
"AAA",
|
||||||
"AARP",
|
"AARP",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { subdomainSchema } from "@server/lib/schemas";
|
|||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
|
import { validateHeaders } from "@server/lib/validators";
|
||||||
|
|
||||||
const updateResourceParamsSchema = z
|
const updateResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -45,7 +46,8 @@ const updateHttpResourceBodySchema = z
|
|||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
tlsServerName: z.string().nullable().optional(),
|
tlsServerName: z.string().nullable().optional(),
|
||||||
setHostHeader: z.string().nullable().optional(),
|
setHostHeader: z.string().nullable().optional(),
|
||||||
skipToIdpId: z.number().int().positive().nullable().optional()
|
skipToIdpId: z.number().int().positive().nullable().optional(),
|
||||||
|
headers: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
@@ -83,6 +85,18 @@ const updateHttpResourceBodySchema = z
|
|||||||
message:
|
message:
|
||||||
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.headers) {
|
||||||
|
return validateHeaders(data.headers)
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons."
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type UpdateResourceResponse = Resource;
|
export type UpdateResourceResponse = Resource;
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import {
|
|||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
@@ -129,7 +130,9 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map());
|
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
const initializeDockerForSite = async (siteId: number) => {
|
const initializeDockerForSite = async (siteId: number) => {
|
||||||
if (dockerStates.has(siteId)) {
|
if (dockerStates.has(siteId)) {
|
||||||
@@ -139,14 +142,14 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const dockerState = await dockerManager.initializeDocker();
|
const dockerState = await dockerManager.initializeDocker();
|
||||||
|
|
||||||
setDockerStates(prev => new Map(prev.set(siteId, dockerState)));
|
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshContainersForSite = async (siteId: number) => {
|
const refreshContainersForSite = async (siteId: number) => {
|
||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const containers = await dockerManager.fetchContainers();
|
const containers = await dockerManager.fetchContainers();
|
||||||
|
|
||||||
setDockerStates(prev => {
|
setDockerStates((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
const existingState = newMap.get(siteId);
|
const existingState = newMap.get(siteId);
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
@@ -157,11 +160,13 @@ export default function ReverseProxyTargets(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getDockerStateForSite = (siteId: number): DockerState => {
|
const getDockerStateForSite = (siteId: number): DockerState => {
|
||||||
return dockerStates.get(siteId) || {
|
return (
|
||||||
isEnabled: false,
|
dockerStates.get(siteId) || {
|
||||||
isAvailable: false,
|
isEnabled: false,
|
||||||
containers: []
|
isAvailable: false,
|
||||||
};
|
containers: []
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
|
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
|
||||||
@@ -185,7 +190,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
{
|
{
|
||||||
message: t("proxyErrorInvalidHeader")
|
message: t("proxyErrorInvalidHeader")
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
headers: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const tlsSettingsSchema = z.object({
|
const tlsSettingsSchema = z.object({
|
||||||
@@ -241,7 +247,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const proxySettingsForm = useForm<ProxySettingsValues>({
|
const proxySettingsForm = useForm<ProxySettingsValues>({
|
||||||
resolver: zodResolver(proxySettingsSchema),
|
resolver: zodResolver(proxySettingsSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
setHostHeader: resource.setHostHeader || ""
|
setHostHeader: resource.setHostHeader || "",
|
||||||
|
headers: resource.headers || ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,7 +305,9 @@ export default function ReverseProxyTargets(props: {
|
|||||||
setSites(res.data.data.sites);
|
setSites(res.data.data.sites);
|
||||||
|
|
||||||
// Initialize Docker for newt sites
|
// Initialize Docker for newt sites
|
||||||
const newtSites = res.data.data.sites.filter(site => site.type === "newt");
|
const newtSites = res.data.data.sites.filter(
|
||||||
|
(site) => site.type === "newt"
|
||||||
|
);
|
||||||
for (const site of newtSites) {
|
for (const site of newtSites) {
|
||||||
initializeDockerForSite(site.siteId);
|
initializeDockerForSite(site.siteId);
|
||||||
}
|
}
|
||||||
@@ -418,11 +427,11 @@ export default function ReverseProxyTargets(props: {
|
|||||||
targets.map((target) =>
|
targets.map((target) =>
|
||||||
target.targetId === targetId
|
target.targetId === targetId
|
||||||
? {
|
? {
|
||||||
...target,
|
...target,
|
||||||
...data,
|
...data,
|
||||||
updated: true,
|
updated: true,
|
||||||
siteType: site?.type || null
|
siteType: site?.type || null
|
||||||
}
|
}
|
||||||
: target
|
: target
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -471,7 +480,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
stickySession: stickySessionData.stickySession,
|
stickySession: stickySessionData.stickySession,
|
||||||
ssl: tlsData.ssl,
|
ssl: tlsData.ssl,
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
setHostHeader: proxyData.setHostHeader || null
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single API call to update all settings
|
// Single API call to update all settings
|
||||||
@@ -483,7 +493,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
stickySession: stickySessionData.stickySession,
|
stickySession: stickySessionData.stickySession,
|
||||||
ssl: tlsData.ssl,
|
ssl: tlsData.ssl,
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
setHostHeader: proxyData.setHostHeader || null
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +557,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!row.original.siteId &&
|
!row.original.siteId &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.original.siteId
|
{row.original.siteId
|
||||||
@@ -597,49 +608,59 @@ export default function ReverseProxyTargets(props: {
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{selectedSite && selectedSite.type === "newt" && (() => {
|
{selectedSite &&
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
selectedSite.type === "newt" &&
|
||||||
return (
|
(() => {
|
||||||
<ContainersSelector
|
const dockerState = getDockerStateForSite(
|
||||||
site={selectedSite}
|
selectedSite.siteId
|
||||||
containers={dockerState.containers}
|
);
|
||||||
isAvailable={dockerState.isAvailable}
|
return (
|
||||||
onContainerSelect={handleContainerSelectForTarget}
|
<ContainersSelector
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
site={selectedSite}
|
||||||
/>
|
containers={dockerState.containers}
|
||||||
);
|
isAvailable={dockerState.isAvailable}
|
||||||
})()}
|
onContainerSelect={
|
||||||
|
handleContainerSelectForTarget
|
||||||
|
}
|
||||||
|
onRefresh={() =>
|
||||||
|
refreshContainersForSite(
|
||||||
|
selectedSite.siteId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...(resource.http
|
...(resource.http
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
accessorKey: "method",
|
accessorKey: "method",
|
||||||
header: t("method"),
|
header: t("method"),
|
||||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.method ?? ""}
|
defaultValue={row.original.method ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: value
|
method: value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{row.original.method}
|
{row.original.method}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
@@ -658,9 +679,13 @@ export default function ReverseProxyTargets(props: {
|
|||||||
if (parsed) {
|
if (parsed) {
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: hasProtocol ? parsed.protocol : row.original.method,
|
method: hasProtocol
|
||||||
|
? parsed.protocol
|
||||||
|
: row.original.method,
|
||||||
ip: parsed.host,
|
ip: parsed.host,
|
||||||
port: hasPort ? parsed.port : row.original.port
|
port: hasPort
|
||||||
|
? parsed.port
|
||||||
|
: row.original.port
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
@@ -807,21 +832,21 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!field.value &&
|
!field.value &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? sites.find(
|
? sites.find(
|
||||||
(
|
(
|
||||||
site
|
site
|
||||||
) =>
|
) =>
|
||||||
site.siteId ===
|
site.siteId ===
|
||||||
field.value
|
field.value
|
||||||
)
|
)
|
||||||
?.name
|
?.name
|
||||||
: t(
|
: t(
|
||||||
"siteSelect"
|
"siteSelect"
|
||||||
)}
|
)}
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -887,18 +912,35 @@ export default function ReverseProxyTargets(props: {
|
|||||||
);
|
);
|
||||||
return selectedSite &&
|
return selectedSite &&
|
||||||
selectedSite.type ===
|
selectedSite.type ===
|
||||||
"newt" ? (() => {
|
"newt"
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
? (() => {
|
||||||
return (
|
const dockerState =
|
||||||
<ContainersSelector
|
getDockerStateForSite(
|
||||||
site={selectedSite}
|
selectedSite.siteId
|
||||||
containers={dockerState.containers}
|
);
|
||||||
isAvailable={dockerState.isAvailable}
|
return (
|
||||||
onContainerSelect={handleContainerSelect}
|
<ContainersSelector
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
site={
|
||||||
/>
|
selectedSite
|
||||||
);
|
}
|
||||||
})() : null;
|
containers={
|
||||||
|
dockerState.containers
|
||||||
|
}
|
||||||
|
isAvailable={
|
||||||
|
dockerState.isAvailable
|
||||||
|
}
|
||||||
|
onContainerSelect={
|
||||||
|
handleContainerSelect
|
||||||
|
}
|
||||||
|
onRefresh={() =>
|
||||||
|
refreshContainersForSite(
|
||||||
|
selectedSite.siteId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -964,25 +1006,59 @@ export default function ReverseProxyTargets(props: {
|
|||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="relative">
|
<FormItem className="relative">
|
||||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("targetAddr")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
id="ip"
|
id="ip"
|
||||||
{...field}
|
{...field}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const input = e.target.value.trim();
|
const input =
|
||||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
e.target.value.trim();
|
||||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
const hasProtocol =
|
||||||
|
/^(https?|h2c):\/\//.test(
|
||||||
|
input
|
||||||
|
);
|
||||||
|
const hasPort =
|
||||||
|
/:\d+(?:\/|$)/.test(
|
||||||
|
input
|
||||||
|
);
|
||||||
|
|
||||||
if (hasProtocol || hasPort) {
|
if (
|
||||||
const parsed = parseHostTarget(input);
|
hasProtocol ||
|
||||||
|
hasPort
|
||||||
|
) {
|
||||||
|
const parsed =
|
||||||
|
parseHostTarget(
|
||||||
|
input
|
||||||
|
);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
if (hasProtocol || !addTargetForm.getValues("method")) {
|
if (
|
||||||
addTargetForm.setValue("method", parsed.protocol);
|
hasProtocol ||
|
||||||
|
!addTargetForm.getValues(
|
||||||
|
"method"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"method",
|
||||||
|
parsed.protocol
|
||||||
|
);
|
||||||
}
|
}
|
||||||
addTargetForm.setValue("ip", parsed.host);
|
addTargetForm.setValue(
|
||||||
if (hasPort || !addTargetForm.getValues("port")) {
|
"ip",
|
||||||
addTargetForm.setValue("port", parsed.port);
|
parsed.host
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hasPort ||
|
||||||
|
!addTargetForm.getValues(
|
||||||
|
"port"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"port",
|
||||||
|
parsed.port
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1091,12 +1167,12 @@ export default function ReverseProxyTargets(props: {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header
|
header
|
||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1256,6 +1332,36 @@ export default function ReverseProxyTargets(props: {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="headers"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-base font-semibold">
|
||||||
|
{t("customHeaders")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<HeadersInput
|
||||||
|
value={
|
||||||
|
field.value || ""
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"customHeadersDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|||||||
69
src/components/HeadersInput.tsx
Normal file
69
src/components/HeadersInput.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface HeadersInputProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadersInput({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
placeholder = `X-Example-Header: example-value
|
||||||
|
X-Another-Header: another-value`,
|
||||||
|
rows = 4,
|
||||||
|
className
|
||||||
|
}: HeadersInputProps) {
|
||||||
|
const [internalValue, setInternalValue] = useState("");
|
||||||
|
|
||||||
|
// Convert comma-separated to newline-separated for display
|
||||||
|
const convertToNewlineSeparated = (commaSeparated: string): string => {
|
||||||
|
if (!commaSeparated || commaSeparated.trim() === "") return "";
|
||||||
|
|
||||||
|
return commaSeparated
|
||||||
|
.split(',')
|
||||||
|
.map(header => header.trim())
|
||||||
|
.filter(header => header.length > 0)
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert newline-separated to comma-separated for output
|
||||||
|
const convertToCommaSeparated = (newlineSeparated: string): string => {
|
||||||
|
if (!newlineSeparated || newlineSeparated.trim() === "") return "";
|
||||||
|
|
||||||
|
return newlineSeparated
|
||||||
|
.split('\n')
|
||||||
|
.map(header => header.trim())
|
||||||
|
.filter(header => header.length > 0)
|
||||||
|
.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update internal value when external value changes
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalValue(convertToNewlineSeparated(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setInternalValue(newValue);
|
||||||
|
|
||||||
|
// Convert back to comma-separated format for the parent
|
||||||
|
const commaSeparatedValue = convertToCommaSeparated(newValue);
|
||||||
|
onChange(commaSeparatedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user