feat: rewrite proxy-host feature components with shadcn

Replace all MUI imports (Stack, Box, Typography, TextField, Switch, Checkbox,
Collapse, Accordion, Chip, etc.) with shadcn/ui + Tailwind equivalents across
all 13 proxy host component files. Lucide icons replace MUI icons throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-22 13:40:47 +01:00
parent 77e354cd7c
commit bca14e0fe0
13 changed files with 1394 additions and 1688 deletions

View File

@@ -1,8 +1,11 @@
import { Box, Checkbox, Collapse, FormControlLabel, Stack, Switch, TextField, Typography } from "@mui/material";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { AuthentikSettings } from "@/src/lib/settings";
import { ProxyHost } from "@/src/lib/models/proxy-hosts";
import { AuthentikSettings } from "@/lib/settings";
import { ProxyHost } from "@/lib/models/proxy-hosts";
const AUTHENTIK_DEFAULT_HEADERS = [
"X-Authentik-Username",
@@ -35,26 +38,28 @@ function HiddenCheckboxField({
helperText?: string;
}) {
return (
<Box>
<div>
<input type="hidden" name={`${name}_present`} value="1" />
<FormControlLabel
control={
<Checkbox
name={name}
defaultChecked={defaultChecked}
disabled={disabled}
size="small"
/>
}
label={<Typography variant="body2">{label}</Typography>}
disabled={disabled}
/>
<div className={cn("flex items-start gap-2", disabled && "opacity-50")}>
<Checkbox
id={`checkbox-${name}`}
name={name}
defaultChecked={defaultChecked}
disabled={disabled}
/>
<label
htmlFor={`checkbox-${name}`}
className={cn("text-sm cursor-pointer", disabled && "cursor-not-allowed")}
>
{label}
</label>
</div>
{helperText && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", ml: 4, mt: -0.5 }}>
<p className="text-xs text-muted-foreground ml-6 -mt-0.5">
{helperText}
</Typography>
</p>
)}
</Box>
</div>
);
}
@@ -77,90 +82,86 @@ export function AuthentikFields({
const setHostHeaderDefault = initial?.setOutpostHostHeader ?? true;
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "primary.main",
bgcolor: "rgba(99, 102, 241, 0.05)",
p: 2.5
}}
>
<div className="rounded-lg border border-primary bg-primary/5 p-5">
<input type="hidden" name="authentik_present" value="1" />
<input type="hidden" name="authentik_enabled_present" value="1" />
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Authentik Forward Auth
</Typography>
<Typography variant="body2" color="text.secondary">
Proxy authentication via Authentik outpost
</Typography>
</Box>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center justify-between">
<div>
<p className="text-sm font-semibold">Authentik Forward Auth</p>
<p className="text-sm text-muted-foreground">Proxy authentication via Authentik outpost</p>
</div>
<Switch
name="authentik_enabled"
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
onCheckedChange={setEnabled}
/>
</Stack>
</div>
<Collapse in={enabled} timeout="auto" unmountOnExit>
<Stack spacing={2}>
<TextField
name="authentik_outpost_domain"
label="Outpost Domain"
placeholder="outpost.goauthentik.io"
defaultValue={initial?.outpostDomain ?? defaults?.outpostDomain ?? ""}
required={enabled}
disabled={!enabled}
fullWidth
/>
<TextField
name="authentik_outpost_upstream"
label="Outpost Upstream URL"
placeholder="https://outpost.internal:9000"
defaultValue={initial?.outpostUpstream ?? defaults?.outpostUpstream ?? ""}
required={enabled}
disabled={!enabled}
fullWidth
/>
{/* ... other fields ... */}
<TextField
name="authentik_auth_endpoint"
label="Auth Endpoint (Optional)"
placeholder="/outpost.goauthentik.io/auth/caddy"
defaultValue={initial?.authEndpoint ?? defaults?.authEndpoint ?? ""}
disabled={!enabled}
fullWidth
/>
<TextField
name="authentik_copy_headers"
label="Headers to Copy"
defaultValue={copyHeadersValue}
disabled={!enabled}
multiline
minRows={3}
fullWidth
/>
<TextField
name="authentik_trusted_proxies"
label="Trusted Proxies"
defaultValue={trustedProxiesValue}
disabled={!enabled}
fullWidth
/>
<TextField
name="authentik_protected_paths"
label="Protected Paths (Optional)"
placeholder="/secret/*, /admin/*"
helperText="Leave empty to protect entire domain. Specify paths to protect specific routes only."
defaultValue={initial?.protectedPaths?.join(", ") ?? ""}
disabled={!enabled}
multiline
minRows={2}
fullWidth
/>
<div className={cn(
"overflow-hidden transition-all duration-200",
enabled ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Outpost Domain</label>
<Input
name="authentik_outpost_domain"
placeholder="outpost.goauthentik.io"
defaultValue={initial?.outpostDomain ?? defaults?.outpostDomain ?? ""}
required={enabled}
disabled={!enabled}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Outpost Upstream URL</label>
<Input
name="authentik_outpost_upstream"
placeholder="https://outpost.internal:9000"
defaultValue={initial?.outpostUpstream ?? defaults?.outpostUpstream ?? ""}
required={enabled}
disabled={!enabled}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Auth Endpoint (Optional)</label>
<Input
name="authentik_auth_endpoint"
placeholder="/outpost.goauthentik.io/auth/caddy"
defaultValue={initial?.authEndpoint ?? defaults?.authEndpoint ?? ""}
disabled={!enabled}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Headers to Copy</label>
<Textarea
name="authentik_copy_headers"
defaultValue={copyHeadersValue}
disabled={!enabled}
rows={3}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Trusted Proxies</label>
<Input
name="authentik_trusted_proxies"
defaultValue={trustedProxiesValue}
disabled={!enabled}
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Protected Paths (Optional)</label>
<Textarea
name="authentik_protected_paths"
placeholder="/secret/*, /admin/*"
defaultValue={initial?.protectedPaths?.join(", ") ?? ""}
disabled={!enabled}
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to protect entire domain. Specify paths to protect specific routes only.
</p>
</div>
<HiddenCheckboxField
name="authentik_set_host_header"
defaultChecked={setHostHeaderDefault}
@@ -168,9 +169,9 @@ export function AuthentikFields({
disabled={!enabled}
helperText="Recommended: Keep enabled. Only disable if using IP-based outpost access or troubleshooting routing issues."
/>
</Stack>
</Collapse>
</Stack>
</Box>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,10 @@
import { Box, Collapse, Stack, Switch, TextField, Typography, Alert } from "@mui/material";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { ProxyHost } from "@/src/lib/models/proxy-hosts";
import { ProxyHost } from "@/lib/models/proxy-hosts";
export function DnsResolverFields({
dnsResolver
@@ -11,75 +15,71 @@ export function DnsResolverFields({
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "warning.main",
bgcolor: "rgba(237, 108, 2, 0.05)",
p: 2.5
}}
>
<div className="rounded-lg border border-yellow-500/60 bg-yellow-500/5 p-5">
<input type="hidden" name="dns_present" value="1" />
<input type="hidden" name="dns_enabled_present" value="1" />
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Custom DNS Resolvers
</Typography>
<Typography variant="body2" color="text.secondary">
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center justify-between">
<div>
<p className="text-sm font-semibold">Custom DNS Resolvers</p>
<p className="text-sm text-muted-foreground">
Configure per-host DNS resolution for upstream discovery and health checks
</Typography>
</Box>
</p>
</div>
<Switch
name="dns_enabled"
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
onCheckedChange={setEnabled}
/>
</Stack>
</div>
<Collapse in={enabled} timeout="auto" unmountOnExit>
<Stack spacing={2.5}>
<TextField
name="dns_resolvers"
label="DNS Resolvers"
placeholder={"1.1.1.1\n8.8.8.8"}
defaultValue={initial?.resolvers?.join("\n") ?? ""}
helperText="One resolver per line (e.g., 1.1.1.1, 8.8.8.8). Used for dynamic upstream DNS resolution."
multiline
minRows={2}
fullWidth
size="small"
/>
<TextField
name="dns_fallbacks"
label="Fallback DNS Resolvers (Optional)"
placeholder={"8.8.4.4\n1.0.0.1"}
defaultValue={initial?.fallbacks?.join("\n") ?? ""}
helperText="Fallback resolvers if primary fails. One per line."
multiline
minRows={2}
fullWidth
size="small"
/>
<TextField
name="dns_timeout"
label="DNS Query Timeout"
placeholder="5s"
defaultValue={initial?.timeout ?? ""}
helperText="Timeout for DNS queries (e.g., 5s, 10s)"
fullWidth
size="small"
/>
<Alert severity="info">
Per-host DNS resolvers override global settings for this specific proxy host.
Useful for upstream services that require specific DNS resolution (e.g., internal DNS, service discovery).
Common resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
<div className={cn(
"overflow-hidden transition-all duration-200",
enabled ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-5">
<div>
<label className="text-sm font-medium mb-1 block">DNS Resolvers</label>
<Textarea
name="dns_resolvers"
placeholder={"1.1.1.1\n8.8.8.8"}
defaultValue={initial?.resolvers?.join("\n") ?? ""}
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">
One resolver per line (e.g., 1.1.1.1, 8.8.8.8). Used for dynamic upstream DNS resolution.
</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Fallback DNS Resolvers (Optional)</label>
<Textarea
name="dns_fallbacks"
placeholder={"8.8.4.4\n1.0.0.1"}
defaultValue={initial?.fallbacks?.join("\n") ?? ""}
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">Fallback resolvers if primary fails. One per line.</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">DNS Query Timeout</label>
<Input
name="dns_timeout"
placeholder="5s"
defaultValue={initial?.timeout ?? ""}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">Timeout for DNS queries (e.g., 5s, 10s)</p>
</div>
<Alert>
<AlertDescription>
Per-host DNS resolvers override global settings for this specific proxy host.
Useful for upstream services that require specific DNS resolution (e.g., internal DNS, service discovery).
Common resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
</AlertDescription>
</Alert>
</Stack>
</Collapse>
</Stack>
</Box>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import { Alert, Box, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useFormState } from "react-dom";
import { useEffect } from "react";
import {
@@ -7,12 +9,12 @@ import {
deleteProxyHostAction,
updateProxyHostAction
} from "@/app/(dashboard)/proxy-hosts/actions";
import { INITIAL_ACTION_STATE } from "@/src/lib/actions";
import { AccessList } from "@/src/lib/models/access-lists";
import { Certificate } from "@/src/lib/models/certificates";
import { ProxyHost } from "@/src/lib/models/proxy-hosts";
import { AuthentikSettings } from "@/src/lib/settings";
import { AppDialog } from "@/src/components/ui/AppDialog";
import { INITIAL_ACTION_STATE } from "@/lib/actions";
import { AccessList } from "@/lib/models/access-lists";
import { Certificate } from "@/lib/models/certificates";
import { ProxyHost } from "@/lib/models/proxy-hosts";
import { AuthentikSettings } from "@/lib/settings";
import { AppDialog } from "@/components/ui/AppDialog";
import { AuthentikFields } from "./AuthentikFields";
import { DnsResolverFields } from "./DnsResolverFields";
import { LoadBalancerFields } from "./LoadBalancerFields";
@@ -24,7 +26,7 @@ import { WafFields } from "./WafFields";
import { MtlsFields } from "./MtlsConfig";
import { RedirectsFields } from "./RedirectsFields";
import { RewriteFields } from "./RewriteFields";
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
import type { CaCertificate } from "@/lib/models/ca-certificates";
export function CreateHostDialog({
open,
@@ -59,14 +61,13 @@ export function CreateHostDialog({
maxWidth="md"
submitLabel="Create"
onSubmit={() => {
// Trigger generic form submit
(document.getElementById("create-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<Stack component="form" id="create-host-form" action={formAction} spacing={2.5}>
<form id="create-host-form" action={formAction} className="flex flex-col gap-5">
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
<Alert variant={state.status === "error" ? "destructive" : "default"}>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)}
<SettingsToggles
@@ -74,64 +75,85 @@ export function CreateHostDialog({
skipHttpsValidation={initialData?.skip_https_hostname_validation}
enabled={true}
/>
<TextField
name="name"
label="Name"
placeholder="My Service"
defaultValue={initialData ? `${initialData.name} (Copy)` : ""}
required
fullWidth
/>
<TextField
name="domains"
label="Domains"
placeholder="app.example.com"
defaultValue={initialData?.domains.join("\n") ?? ""}
helperText="One per line or comma-separated. Wildcards like *.example.com are supported."
multiline
minRows={2}
required
fullWidth
/>
<div>
<label className="text-sm font-medium mb-1 block">Name</label>
<Input
name="name"
placeholder="My Service"
defaultValue={initialData ? `${initialData.name} (Copy)` : ""}
required
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Domains</label>
<Textarea
name="domains"
placeholder="app.example.com"
defaultValue={initialData?.domains.join("\n") ?? ""}
required
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">
One per line or comma-separated. Wildcards like *.example.com are supported.
</p>
</div>
<UpstreamInput defaultUpstreams={initialData?.upstreams} />
<TextField select name="certificate_id" label="Certificate" defaultValue={initialData?.certificate_id ?? ""} fullWidth>
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
{certificates.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.name}
</MenuItem>
))}
</TextField>
<TextField select name="access_list_id" label="Access List" defaultValue={initialData?.access_list_id ?? ""} fullWidth>
<MenuItem value="">None</MenuItem>
{accessLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.name}
</MenuItem>
))}
</TextField>
<div>
<label className="text-sm font-medium mb-1 block">Certificate</label>
<Select name="certificate_id" defaultValue={String(initialData?.certificate_id ?? "")}>
<SelectTrigger>
<SelectValue placeholder="Managed by Caddy (Auto)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Managed by Caddy (Auto)</SelectItem>
{certificates.map((cert) => (
<SelectItem key={cert.id} value={String(cert.id)}>
{cert.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Access List</label>
<Select name="access_list_id" defaultValue={String(initialData?.access_list_id ?? "")}>
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{accessLists.map((list) => (
<SelectItem key={list.id} value={String(list.id)}>
{list.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<RedirectsFields initialData={initialData?.redirects} />
<RewriteFields initialData={initialData?.rewrite} />
<TextField
name="custom_pre_handlers_json"
label="Custom Pre-Handlers (JSON)"
placeholder='[{"handler": "headers", ...}]'
defaultValue={initialData?.custom_pre_handlers_json ?? ""}
helperText="Optional JSON array of Caddy handlers"
multiline
minRows={3}
fullWidth
/>
<TextField
name="custom_reverse_proxy_json"
label="Custom Reverse Proxy (JSON)"
placeholder='{"headers": {"request": {...}}}'
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
helperText="Deep-merge into reverse_proxy handler (only applies in proxy mode)"
multiline
minRows={3}
fullWidth
/>
<div>
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
<Textarea
name="custom_pre_handlers_json"
placeholder='[{"handler": "headers", ...}]'
defaultValue={initialData?.custom_pre_handlers_json ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Custom Reverse Proxy (JSON)</label>
<Textarea
name="custom_reverse_proxy_json"
placeholder='{"headers": {"request": {...}}}'
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
Deep-merge into reverse_proxy handler (only applies in proxy mode)
</p>
</div>
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
@@ -139,7 +161,7 @@ export function CreateHostDialog({
<GeoBlockFields />
<WafFields value={initialData?.waf} />
<MtlsFields value={initialData?.mtls} caCertificates={caCertificates} />
</Stack>
</form>
</AppDialog>
);
}
@@ -178,10 +200,10 @@ export function EditHostDialog({
(document.getElementById("edit-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<Stack component="form" id="edit-host-form" action={formAction} spacing={2.5}>
<form id="edit-host-form" action={formAction} className="flex flex-col gap-5">
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
<Alert variant={state.status === "error" ? "destructive" : "default"}>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)}
<SettingsToggles
@@ -189,66 +211,89 @@ export function EditHostDialog({
skipHttpsValidation={host.skip_https_hostname_validation}
enabled={host.enabled}
/>
<TextField name="name" label="Name" defaultValue={host.name} required fullWidth />
<TextField
name="domains"
label="Domains"
defaultValue={host.domains.join("\n")}
helperText="One per line or comma-separated. Wildcards like *.example.com are supported."
multiline
minRows={2}
fullWidth
/>
<div>
<label className="text-sm font-medium mb-1 block">Name</label>
<Input name="name" defaultValue={host.name} required />
</div>
<div>
<label className="text-sm font-medium mb-1 block">Domains</label>
<Textarea
name="domains"
defaultValue={host.domains.join("\n")}
rows={2}
/>
<p className="text-xs text-muted-foreground mt-1">
One per line or comma-separated. Wildcards like *.example.com are supported.
</p>
</div>
<UpstreamInput defaultUpstreams={host.upstreams} />
<TextField select name="certificate_id" label="Certificate" defaultValue={host.certificate_id ?? ""} fullWidth>
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
{certificates.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.name}
</MenuItem>
))}
</TextField>
<TextField select name="access_list_id" label="Access List" defaultValue={host.access_list_id ?? ""} fullWidth>
<MenuItem value="">None</MenuItem>
{accessLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.name}
</MenuItem>
))}
</TextField>
<div>
<label className="text-sm font-medium mb-1 block">Certificate</label>
<Select name="certificate_id" defaultValue={String(host.certificate_id ?? "")}>
<SelectTrigger>
<SelectValue placeholder="Managed by Caddy (Auto)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Managed by Caddy (Auto)</SelectItem>
{certificates.map((cert) => (
<SelectItem key={cert.id} value={String(cert.id)}>
{cert.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Access List</label>
<Select name="access_list_id" defaultValue={String(host.access_list_id ?? "")}>
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{accessLists.map((list) => (
<SelectItem key={list.id} value={String(list.id)}>
{list.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<RedirectsFields initialData={host.redirects} />
<RewriteFields initialData={host.rewrite} />
<TextField
name="custom_pre_handlers_json"
label="Custom Pre-Handlers (JSON)"
defaultValue={host.custom_pre_handlers_json ?? ""}
helperText="Optional JSON array of Caddy handlers"
multiline
minRows={3}
fullWidth
/>
<TextField
name="custom_reverse_proxy_json"
label="Custom Reverse Proxy (JSON)"
defaultValue={host.custom_reverse_proxy_json ?? ""}
helperText="Deep-merge into reverse_proxy handler (only applies in proxy mode)"
multiline
minRows={3}
fullWidth
/>
<div>
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
<Textarea
name="custom_pre_handlers_json"
defaultValue={host.custom_pre_handlers_json ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Custom Reverse Proxy (JSON)</label>
<Textarea
name="custom_reverse_proxy_json"
defaultValue={host.custom_reverse_proxy_json ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
Deep-merge into reverse_proxy handler (only applies in proxy mode)
</p>
</div>
<AuthentikFields authentik={host.authentik} />
<LoadBalancerFields loadBalancer={host.load_balancer} />
<DnsResolverFields dnsResolver={host.dns_resolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstream_dns_resolution} />
<GeoBlockFields
initialValues={{
geoblock: host.geoblock,
geoblock_mode: host.geoblock_mode,
}}
initialValues={{
geoblock: host.geoblock,
geoblock_mode: host.geoblock_mode,
}}
/>
<WafFields value={host.waf} />
<MtlsFields value={host.mtls} caCertificates={caCertificates} />
</Stack>
</form>
</AppDialog>
);
}
@@ -281,30 +326,26 @@ export function DeleteHostDialog({
(document.getElementById("delete-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<Stack component="form" id="delete-host-form" action={formAction} spacing={2}>
<form id="delete-host-form" action={formAction} className="flex flex-col gap-4">
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
<Alert variant={state.status === "error" ? "destructive" : "default"}>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
)}
<Typography variant="body1">
<p className="text-sm">
Are you sure you want to delete the proxy host <strong>{host.name}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary">
</p>
<p className="text-sm text-muted-foreground">
This will remove the configuration for:
</Typography>
<Box sx={{ pl: 2 }}>
<Typography variant="body2" color="text.secondary">
Domains: {host.domains.join(", ")}
</Typography>
<Typography variant="body2" color="text.secondary">
Upstreams: {host.upstreams.join(", ")}
</Typography>
</Box>
<Typography variant="body2" color="error.main" fontWeight={500}>
</p>
<div className="pl-4">
<p className="text-sm text-muted-foreground"> Domains: {host.domains.join(", ")}</p>
<p className="text-sm text-muted-foreground"> Upstreams: {host.upstreams.join(", ")}</p>
</div>
<p className="text-sm text-destructive font-medium">
This action cannot be undone.
</Typography>
</Stack>
</p>
</form>
</AppDialog>
);
}

View File

@@ -1,7 +1,9 @@
import { Box, Collapse, FormControlLabel, Stack, Switch, TextField, Typography, MenuItem } from "@mui/material";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { ProxyHost, LoadBalancingPolicy } from "@/src/lib/models/proxy-hosts";
import { ProxyHost, LoadBalancingPolicy } from "@/lib/models/proxy-hosts";
const LOAD_BALANCING_POLICIES = [
{ value: "random", label: "Random", description: "Random selection (default)" },
@@ -29,311 +31,245 @@ export function LoadBalancerFields({
const showCookieFields = policy === "cookie";
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "info.main",
bgcolor: "rgba(2, 136, 209, 0.05)",
p: 2.5
}}
>
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-5">
<input type="hidden" name="lb_present" value="1" />
<input type="hidden" name="lb_enabled_present" value="1" />
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Load Balancer
</Typography>
<Typography variant="body2" color="text.secondary">
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center justify-between">
<div>
<p className="text-sm font-semibold">Load Balancer</p>
<p className="text-sm text-muted-foreground">
Configure load balancing and health checks for multiple upstreams
</Typography>
</Box>
</p>
</div>
<Switch
name="lb_enabled"
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
onCheckedChange={setEnabled}
/>
</Stack>
</div>
<Collapse in={enabled} timeout="auto" unmountOnExit>
<Stack spacing={2.5}>
<div className={cn(
"overflow-hidden transition-all duration-200",
enabled ? "max-h-[3000px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-6">
{/* Policy Selection */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Selection Policy
</Typography>
<TextField
select
name="lb_policy"
label="Load Balancing Policy"
value={policy}
onChange={(e) => setPolicy(e.target.value as LoadBalancingPolicy)}
fullWidth
size="small"
>
{LOAD_BALANCING_POLICIES.map((p) => (
<MenuItem key={p.value} value={p.value}>
{p.label} - {p.description}
</MenuItem>
))}
</TextField>
</Box>
<div>
<p className="text-sm font-semibold mb-2">Selection Policy</p>
<input type="hidden" name="lb_policy" value={policy} />
<Select value={policy} onValueChange={(v) => setPolicy(v as LoadBalancingPolicy)}>
<SelectTrigger>
<SelectValue placeholder="Select policy" />
</SelectTrigger>
<SelectContent>
{LOAD_BALANCING_POLICIES.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label} - {p.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Header-based policy fields */}
<Collapse in={showHeaderField} timeout="auto" unmountOnExit>
<TextField
name="lb_policy_header_field"
label="Header Field Name"
placeholder="X-Custom-Header"
defaultValue={initial?.policyHeaderField ?? ""}
helperText="The request header to hash for upstream selection"
fullWidth
size="small"
/>
</Collapse>
<div className={cn(
"overflow-hidden transition-all duration-200",
showHeaderField ? "max-h-[200px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div>
<label className="text-sm font-medium mb-1 block">Header Field Name</label>
<Input
name="lb_policy_header_field"
placeholder="X-Custom-Header"
defaultValue={initial?.policyHeaderField ?? ""}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">The request header to hash for upstream selection</p>
</div>
</div>
{/* Cookie-based policy fields */}
<Collapse in={showCookieFields} timeout="auto" unmountOnExit>
<Stack spacing={2}>
<TextField
name="lb_policy_cookie_name"
label="Cookie Name"
placeholder="server_id"
defaultValue={initial?.policyCookieName ?? ""}
helperText="Name of the cookie for sticky sessions"
fullWidth
size="small"
/>
<TextField
name="lb_policy_cookie_secret"
label="Cookie Secret (Optional)"
placeholder="your-secret-key"
defaultValue={initial?.policyCookieSecret ?? ""}
helperText="Secret key for HMAC cookie signing"
fullWidth
size="small"
/>
</Stack>
</Collapse>
<div className={cn(
"overflow-hidden transition-all duration-200",
showCookieFields ? "max-h-[300px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Cookie Name</label>
<Input
name="lb_policy_cookie_name"
placeholder="server_id"
defaultValue={initial?.policyCookieName ?? ""}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">Name of the cookie for sticky sessions</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Cookie Secret (Optional)</label>
<Input
name="lb_policy_cookie_secret"
placeholder="your-secret-key"
defaultValue={initial?.policyCookieSecret ?? ""}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">Secret key for HMAC cookie signing</p>
</div>
</div>
</div>
{/* Retry Settings */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Retry Settings
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
name="lb_try_duration"
label="Try Duration"
placeholder="5s"
defaultValue={initial?.tryDuration ?? ""}
helperText="How long to try upstreams"
fullWidth
size="small"
/>
<TextField
name="lb_try_interval"
label="Try Interval"
placeholder="250ms"
defaultValue={initial?.tryInterval ?? ""}
helperText="Wait between attempts"
fullWidth
size="small"
/>
<TextField
name="lb_retries"
label="Max Retries"
type="number"
inputProps={{ min: 0 }}
defaultValue={initial?.retries ?? ""}
helperText="Maximum retry attempts"
fullWidth
size="small"
/>
</Stack>
</Box>
<div>
<p className="text-sm font-semibold mb-2">Retry Settings</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Try Duration</label>
<Input
name="lb_try_duration"
placeholder="5s"
defaultValue={initial?.tryDuration ?? ""}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">How long to try upstreams</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Try Interval</label>
<Input
name="lb_try_interval"
placeholder="250ms"
defaultValue={initial?.tryInterval ?? ""}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">Wait between attempts</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Max Retries</label>
<Input
name="lb_retries"
type="number"
min={0}
defaultValue={initial?.retries ?? ""}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">Maximum retry attempts</p>
</div>
</div>
</div>
{/* Active Health Checks */}
<Box
sx={{
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
p: 2
}}
>
<div className="rounded-lg border border-border p-4">
<input type="hidden" name="lb_active_health_enabled_present" value="1" />
<Stack spacing={2}>
<FormControlLabel
control={
<Switch
name="lb_active_health_enabled"
checked={activeHealthEnabled}
onChange={(_, checked) => setActiveHealthEnabled(checked)}
size="small"
/>
}
label={
<Box>
<Typography variant="subtitle2">Active Health Checks</Typography>
<Typography variant="caption" color="text.secondary">
Periodically probe upstreams to check health
</Typography>
</Box>
}
/>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<Switch
name="lb_active_health_enabled"
checked={activeHealthEnabled}
onCheckedChange={setActiveHealthEnabled}
/>
<div>
<p className="text-sm font-semibold">Active Health Checks</p>
<span className="text-xs text-muted-foreground">Periodically probe upstreams to check health</span>
</div>
</div>
<Collapse in={activeHealthEnabled} timeout="auto" unmountOnExit>
<Stack spacing={2}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
name="lb_active_health_uri"
label="Health Check URI"
placeholder="/health"
defaultValue={initial?.activeHealthCheck?.uri ?? ""}
helperText="Path to probe for health"
fullWidth
size="small"
/>
<TextField
name="lb_active_health_port"
label="Health Check Port"
type="number"
inputProps={{ min: 1, max: 65535 }}
defaultValue={initial?.activeHealthCheck?.port ?? ""}
helperText="Override upstream port"
fullWidth
size="small"
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
name="lb_active_health_interval"
label="Check Interval"
placeholder="30s"
defaultValue={initial?.activeHealthCheck?.interval ?? ""}
helperText="How often to check"
fullWidth
size="small"
/>
<TextField
name="lb_active_health_timeout"
label="Check Timeout"
placeholder="5s"
defaultValue={initial?.activeHealthCheck?.timeout ?? ""}
helperText="Timeout for health probe"
fullWidth
size="small"
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
name="lb_active_health_status"
label="Expected Status Code"
type="number"
inputProps={{ min: 100, max: 599 }}
defaultValue={initial?.activeHealthCheck?.status ?? ""}
helperText="Expected HTTP status"
fullWidth
size="small"
/>
<TextField
name="lb_active_health_body"
label="Expected Body"
placeholder="OK"
defaultValue={initial?.activeHealthCheck?.body ?? ""}
helperText="Expected response body"
fullWidth
size="small"
/>
</Stack>
</Stack>
</Collapse>
</Stack>
</Box>
<div className={cn(
"overflow-hidden transition-all duration-200",
activeHealthEnabled ? "max-h-[500px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Health Check URI</label>
<Input name="lb_active_health_uri" placeholder="/health" defaultValue={initial?.activeHealthCheck?.uri ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Path to probe for health</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Health Check Port</label>
<Input name="lb_active_health_port" type="number" min={1} max={65535} defaultValue={initial?.activeHealthCheck?.port ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Override upstream port</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Check Interval</label>
<Input name="lb_active_health_interval" placeholder="30s" defaultValue={initial?.activeHealthCheck?.interval ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">How often to check</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Check Timeout</label>
<Input name="lb_active_health_timeout" placeholder="5s" defaultValue={initial?.activeHealthCheck?.timeout ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Timeout for health probe</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Expected Status Code</label>
<Input name="lb_active_health_status" type="number" min={100} max={599} defaultValue={initial?.activeHealthCheck?.status ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Expected HTTP status</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Expected Body</label>
<Input name="lb_active_health_body" placeholder="OK" defaultValue={initial?.activeHealthCheck?.body ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Expected response body</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Passive Health Checks */}
<Box
sx={{
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
p: 2
}}
>
<div className="rounded-lg border border-border p-4">
<input type="hidden" name="lb_passive_health_enabled_present" value="1" />
<Stack spacing={2}>
<FormControlLabel
control={
<Switch
name="lb_passive_health_enabled"
checked={passiveHealthEnabled}
onChange={(_, checked) => setPassiveHealthEnabled(checked)}
size="small"
/>
}
label={
<Box>
<Typography variant="subtitle2">Passive Health Checks</Typography>
<Typography variant="caption" color="text.secondary">
Mark upstreams unhealthy based on response failures
</Typography>
</Box>
}
/>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<Switch
name="lb_passive_health_enabled"
checked={passiveHealthEnabled}
onCheckedChange={setPassiveHealthEnabled}
/>
<div>
<p className="text-sm font-semibold">Passive Health Checks</p>
<span className="text-xs text-muted-foreground">Mark upstreams unhealthy based on response failures</span>
</div>
</div>
<Collapse in={passiveHealthEnabled} timeout="auto" unmountOnExit>
<Stack spacing={2}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
name="lb_passive_health_fail_duration"
label="Fail Duration"
placeholder="30s"
defaultValue={initial?.passiveHealthCheck?.failDuration ?? ""}
helperText="How long to remember failures"
fullWidth
size="small"
/>
<TextField
name="lb_passive_health_max_fails"
label="Max Failures"
type="number"
inputProps={{ min: 0 }}
defaultValue={initial?.passiveHealthCheck?.maxFails ?? ""}
helperText="Failures before marking unhealthy"
fullWidth
size="small"
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
name="lb_passive_health_unhealthy_status"
label="Unhealthy Status Codes"
placeholder="500, 502, 503"
defaultValue={initial?.passiveHealthCheck?.unhealthyStatus?.join(", ") ?? ""}
helperText="Comma-separated status codes"
fullWidth
size="small"
/>
<TextField
name="lb_passive_health_unhealthy_latency"
label="Unhealthy Latency"
placeholder="5s"
defaultValue={initial?.passiveHealthCheck?.unhealthyLatency ?? ""}
helperText="Latency threshold for unhealthy"
fullWidth
size="small"
/>
</Stack>
</Stack>
</Collapse>
</Stack>
</Box>
</Stack>
</Collapse>
</Stack>
</Box>
<div className={cn(
"overflow-hidden transition-all duration-200",
passiveHealthEnabled ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Fail Duration</label>
<Input name="lb_passive_health_fail_duration" placeholder="30s" defaultValue={initial?.passiveHealthCheck?.failDuration ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">How long to remember failures</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Max Failures</label>
<Input name="lb_passive_health_max_fails" type="number" min={0} defaultValue={initial?.passiveHealthCheck?.maxFails ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Failures before marking unhealthy</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Unhealthy Status Codes</label>
<Input name="lb_passive_health_unhealthy_status" placeholder="500, 502, 503" defaultValue={initial?.passiveHealthCheck?.unhealthyStatus?.join(", ") ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Comma-separated status codes</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Unhealthy Latency</label>
<Input name="lb_passive_health_unhealthy_latency" placeholder="5s" defaultValue={initial?.passiveHealthCheck?.unhealthyLatency ?? ""} className="h-8 text-sm" />
<p className="text-xs text-muted-foreground mt-1">Latency threshold for unhealthy</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,19 +1,13 @@
"use client";
import {
Alert,
Box,
Checkbox,
Collapse,
FormControlLabel,
Stack,
Switch,
Typography,
} from "@mui/material";
import LockPersonIcon from "@mui/icons-material/LockPerson";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { LockKeyhole } from "lucide-react";
import { useState } from "react";
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
import type { MtlsConfig } from "@/src/lib/models/proxy-hosts";
import type { CaCertificate } from "@/lib/models/ca-certificates";
import type { MtlsConfig } from "@/lib/models/proxy-hosts";
type Props = {
value?: MtlsConfig | null;
@@ -31,16 +25,7 @@ export function MtlsFields({ value, caCertificates }: Props) {
}
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "info.main",
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "rgba(2,136,209,0.06)" : "rgba(2,136,209,0.04)",
p: 2,
}}
>
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-4">
<input type="hidden" name="mtls_present" value="1" />
<input type="hidden" name="mtls_enabled" value={enabled ? "true" : "false"} />
{enabled && selectedIds.map(id => (
@@ -48,79 +33,60 @@ export function MtlsFields({ value, caCertificates }: Props) {
))}
{/* Header */}
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
<Stack direction="row" alignItems="flex-start" spacing={1.5} flex={1} minWidth={0}>
<Box
sx={{
mt: 0.25,
width: 32,
height: 32,
borderRadius: 1.5,
bgcolor: "info.main",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<LockPersonIcon sx={{ fontSize: 18, color: "#fff" }} />
</Box>
<Box minWidth={0}>
<Typography variant="subtitle1" fontWeight={700} lineHeight={1.3}>
Mutual TLS (mTLS)
</Typography>
<Typography variant="body2" color="text.secondary" mt={0.25}>
<div className="flex flex-row items-start justify-between gap-2">
<div className="flex flex-row items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5 w-8 h-8 rounded-xl bg-blue-500 flex items-center justify-center shrink-0">
<LockKeyhole className="h-4 w-4 text-white" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold leading-snug">Mutual TLS (mTLS)</p>
<p className="text-sm text-muted-foreground mt-0.5">
Require clients to present a certificate signed by a trusted CA
</Typography>
</Box>
</Stack>
</p>
</div>
</div>
<Switch
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
sx={{ flexShrink: 0 }}
onCheckedChange={setEnabled}
className="shrink-0"
/>
</Stack>
</div>
<Collapse in={enabled} timeout="auto" unmountOnExit>
<Box mt={2}>
<Alert severity="info" sx={{ mb: 2 }}>
<div className={cn(
"overflow-hidden transition-all duration-200",
enabled ? "max-h-[1000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
)}>
<Alert className="mb-4">
<AlertDescription>
mTLS requires TLS to be configured on this host (certificate must be set).
</Alert>
</AlertDescription>
</Alert>
<Typography
variant="caption"
color="text.secondary"
fontWeight={600}
sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}
>
Trusted Client CA Certificates
</Typography>
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
Trusted Client CA Certificates
</span>
{caCertificates.length === 0 ? (
<Typography variant="body2" color="text.secondary" mt={1}>
No CA certificates configured. Add them on the Certificates page.
</Typography>
) : (
<Stack mt={0.5}>
{caCertificates.map(ca => (
<FormControlLabel
key={ca.id}
control={
<Checkbox
checked={selectedIds.includes(ca.id)}
onChange={() => toggleId(ca.id)}
size="small"
/>
}
label={
<Typography variant="body2">{ca.name}</Typography>
}
{caCertificates.length === 0 ? (
<p className="text-sm text-muted-foreground mt-2">
No CA certificates configured. Add them on the Certificates page.
</p>
) : (
<div className="flex flex-col mt-1">
{caCertificates.map(ca => (
<div key={ca.id} className="flex items-center gap-2 py-1">
<Checkbox
id={`ca-cert-${ca.id}`}
checked={selectedIds.includes(ca.id)}
onCheckedChange={() => toggleId(ca.id)}
/>
))}
</Stack>
)}
</Box>
</Collapse>
</Box>
<label htmlFor={`ca-cert-${ca.id}`} className="text-sm cursor-pointer">
{ca.name}
</label>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,12 +1,10 @@
"use client";
import { useState } from "react";
import {
Box, Button, IconButton, MenuItem, Select,
Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import AddIcon from "@mui/icons-material/Add";
import type { RedirectRule } from "@/src/lib/models/proxy-hosts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Trash2, Plus } from "lucide-react";
import type { RedirectRule } from "@/lib/models/proxy-hosts";
type Props = { initialData?: RedirectRule[] };
@@ -23,66 +21,65 @@ export function RedirectsFields({ initialData = [] }: Props) {
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, [key]: value } : rule)));
return (
<Box>
<Typography variant="subtitle2" gutterBottom>
Redirects
</Typography>
<div>
<p className="text-sm font-semibold mb-2">Redirects</p>
<input type="hidden" name="redirects_json" value={JSON.stringify(rules)} />
{rules.length > 0 && (
<Table size="small" sx={{ mb: 1 }}>
<TableHead>
<TableRow>
<TableCell>From Path</TableCell>
<TableCell>To URL / Path</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
<div className="mb-2">
<div className="grid grid-cols-[1fr_1fr_90px_40px] gap-2 mb-1">
<span className="text-xs font-medium text-muted-foreground px-1">From Path</span>
<span className="text-xs font-medium text-muted-foreground px-1">To URL / Path</span>
<span className="text-xs font-medium text-muted-foreground px-1">Status</span>
<span />
</div>
<div className="flex flex-col gap-2">
{rules.map((rule, i) => (
<TableRow key={i}>
<TableCell>
<TextField
size="small"
placeholder="/.well-known/carddav"
value={rule.from}
onChange={(e) => updateRule(i, "from", e.target.value)}
fullWidth
/>
</TableCell>
<TableCell>
<TextField
size="small"
placeholder="/remote.php/dav/"
value={rule.to}
onChange={(e) => updateRule(i, "to", e.target.value)}
fullWidth
/>
</TableCell>
<TableCell sx={{ width: 90 }}>
<Select
size="small"
value={rule.status}
onChange={(e) => updateRule(i, "status", Number(e.target.value))}
>
<div key={i} className="grid grid-cols-[1fr_1fr_90px_40px] gap-2 items-center">
<Input
size={1}
placeholder="/.well-known/carddav"
value={rule.from}
onChange={(e) => updateRule(i, "from", e.target.value)}
className="h-8 text-sm"
/>
<Input
size={1}
placeholder="/remote.php/dav/"
value={rule.to}
onChange={(e) => updateRule(i, "to", e.target.value)}
className="h-8 text-sm"
/>
<Select
value={String(rule.status)}
onValueChange={(v) => updateRule(i, "status", Number(v))}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[301, 302, 307, 308].map((s) => (
<MenuItem key={s} value={s}>{s}</MenuItem>
<SelectItem key={s} value={String(s)}>{s}</SelectItem>
))}
</Select>
</TableCell>
<TableCell sx={{ width: 40 }}>
<IconButton size="small" onClick={() => removeRule(i)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeRule(i)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</TableBody>
</Table>
</div>
</div>
)}
<Button size="small" startIcon={<AddIcon />} onClick={addRule}>
<Button type="button" variant="ghost" size="sm" onClick={addRule}>
<Plus className="h-4 w-4 mr-1" />
Add Redirect
</Button>
</Box>
</div>
);
}

View File

@@ -1,17 +1,20 @@
import { TextField } from "@mui/material";
import type { RewriteConfig } from "@/src/lib/models/proxy-hosts";
import { Input } from "@/components/ui/input";
import type { RewriteConfig } from "@/lib/models/proxy-hosts";
type Props = { initialData?: RewriteConfig | null };
export function RewriteFields({ initialData }: Props) {
return (
<TextField
name="rewrite_path_prefix"
label="Path Prefix Rewrite"
placeholder="/recipes"
defaultValue={initialData?.path_prefix ?? ""}
helperText="Prepend this prefix to every request before proxying (e.g. /recipes → /recipes/original/path)"
fullWidth
/>
<div>
<label className="text-sm font-medium mb-1 block">Path Prefix Rewrite</label>
<Input
name="rewrite_path_prefix"
placeholder="/recipes"
defaultValue={initialData?.path_prefix ?? ""}
/>
<p className="text-xs text-muted-foreground mt-1">
Prepend this prefix to every request before proxying (e.g. /recipes /recipes/original/path)
</p>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { Box, Stack, Switch, Typography } from "@mui/material";
import type { SwitchProps } from "@mui/material";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useState } from "react";
type ToggleSetting = {
@@ -8,7 +7,6 @@ type ToggleSetting = {
label: string;
description: string;
defaultChecked: boolean;
color?: SwitchProps["color"];
};
type SettingsTogglesProps = {
@@ -28,12 +26,8 @@ export function SettingsToggles({
enabled: enabled
});
const handleChange = (name: keyof typeof values) => (event: React.ChangeEvent<HTMLInputElement>) => {
setValues(prev => ({ ...prev, [name]: event.target.checked }));
};
const handleEnabledChange = (_: React.ChangeEvent<HTMLInputElement>, checked: boolean) => {
setValues(prev => ({ ...prev, enabled: checked }));
const handleChange = (name: keyof typeof values) => (checked: boolean) => {
setValues(prev => ({ ...prev, [name]: checked }));
};
const settings: ToggleSetting[] = [
@@ -42,98 +36,67 @@ export function SettingsToggles({
label: "HSTS Subdomains",
description: "Include subdomains in the Strict-Transport-Security header",
defaultChecked: values.hsts_subdomains,
color: "primary"
},
{
name: "skip_https_hostname_validation",
label: "Skip HTTPS Validation",
description: "Skip SSL certificate hostname verification for backend connections",
defaultChecked: values.skip_https_hostname_validation,
color: "primary"
}
];
return (
<Stack spacing={3}>
<div className="flex flex-col gap-6">
<input type="hidden" name="enabled_present" value="1" />
<input type="hidden" name="enabled" value={values.enabled ? "on" : ""} />
{/* Main Enable Switch */}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{
p: 2,
borderRadius: 2,
border: "1px solid",
borderColor: values.enabled ? "primary.main" : "divider",
bgcolor: values.enabled ? "rgba(99, 102, 241, 0.04)" : "background.paper",
transition: "all 0.2s ease"
}}
>
<Box>
<Typography variant="subtitle1" fontWeight={600} color={values.enabled ? "primary.main" : "text.primary"}>
<div className={cn(
"flex flex-row items-center justify-between p-4 rounded-lg border transition-all duration-200",
values.enabled
? "border-primary bg-primary/5"
: "border-border bg-background"
)}>
<div>
<p className={cn("text-sm font-semibold", values.enabled ? "text-primary" : "text-foreground")}>
{values.enabled ? "Proxy Host Enabled" : "Proxy Host Paused"}
</Typography>
<Typography variant="body2" color="text.secondary">
</p>
<p className="text-sm text-muted-foreground">
{values.enabled
? "This host is active and routing traffic"
: "This host is disabled and will not respond to requests"}
</Typography>
</Box>
</p>
</div>
<Switch
checked={values.enabled}
onChange={handleEnabledChange}
color="primary"
onCheckedChange={handleChange("enabled")}
/>
</Stack>
</div>
{/* Advanced Options */}
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
bgcolor: "background.paper",
overflow: "hidden"
}}
>
<Box sx={{ px: 2, py: 1.5, borderBottom: "1px solid", borderColor: "divider", bgcolor: "rgba(255,255,255,0.02)" }}>
<Typography variant="subtitle2" fontWeight={600}>
Advanced Options
</Typography>
</Box>
<Stack divider={<Box sx={{ borderBottom: "1px solid", borderColor: "divider" }} />}>
<div className="rounded-lg border border-border bg-background overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-white/5 dark:bg-white/2">
<p className="text-sm font-semibold">Advanced Options</p>
</div>
<div className="divide-y divide-border">
{settings.map((setting) => (
<Box key={setting.name}>
<div key={setting.name}>
<input type="hidden" name={`${setting.name}_present`} value="1" />
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 2, py: 1.5 }}
>
<Box sx={{ pr: 2 }}>
<Typography variant="body2" fontWeight={500}>
{setting.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{setting.description}
</Typography>
</Box>
<div className="flex flex-row items-center justify-between px-4 py-3">
<div className="pr-4">
<p className="text-sm font-medium">{setting.label}</p>
<span className="text-xs text-muted-foreground">{setting.description}</span>
</div>
<Switch
name={setting.name}
checked={values[setting.name]}
onChange={handleChange(setting.name)}
size="small"
color={setting.color}
onCheckedChange={handleChange(setting.name)}
/>
</Stack>
</Box>
</div>
</div>
))}
</Stack>
</Box>
</Stack>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,10 @@
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { Alert, Box, Collapse, IconButton, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import type { ProxyHost } from "@/src/lib/models/proxy-hosts";
import type { ProxyHost } from "@/lib/models/proxy-hosts";
type ResolutionMode = "inherit" | "enabled" | "disabled";
type FamilyMode = "inherit" | "ipv6" | "ipv4" | "both";
@@ -27,80 +30,84 @@ export function UpstreamDnsResolutionFields({
const mode = toResolutionMode(upstreamDnsResolution?.enabled);
const family = toFamilyMode(upstreamDnsResolution?.family);
const [expanded, setExpanded] = useState(mode !== "inherit" || family !== "inherit");
const summary = mode === "inherit" && family === "inherit"
const [currentMode, setCurrentMode] = useState<ResolutionMode>(mode);
const [currentFamily, setCurrentFamily] = useState<FamilyMode>(family);
const summary = currentMode === "inherit" && currentFamily === "inherit"
? "Using global upstream DNS pinning defaults"
: `Override: ${mode === "inherit" ? "inherit mode" : mode}, ${family === "inherit" ? "inherit family" : family}`;
: `Override: ${currentMode === "inherit" ? "inherit mode" : currentMode}, ${currentFamily === "inherit" ? "inherit family" : currentFamily}`;
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "info.main",
bgcolor: "rgba(2, 136, 209, 0.06)",
p: 2.5
}}
>
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-5">
<input type="hidden" name="upstream_dns_resolution_present" value="1" />
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Upstream DNS Pinning
</Typography>
<Typography variant="body2" color="text.secondary">
{summary}
</Typography>
</Box>
<IconButton
size="small"
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center justify-between">
<div>
<p className="text-sm font-semibold">Upstream DNS Pinning</p>
<p className="text-sm text-muted-foreground">{summary}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={expanded ? "Collapse upstream DNS pinning options" : "Expand upstream DNS pinning options"}
onClick={() => setExpanded(prev => !prev)}
sx={{
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease"
}}
className="h-8 w-8"
>
<ExpandMoreIcon fontSize="small" />
</IconButton>
</Stack>
<ChevronDown className={cn(
"h-4 w-4 transition-transform duration-200",
expanded && "rotate-180"
)} />
</Button>
</div>
<Collapse in={expanded} timeout="auto" unmountOnExit={false}>
<Stack spacing={2}>
<TextField
name="upstream_dns_resolution_mode"
label="Resolution Mode"
select
defaultValue={mode}
helperText="Inherit uses the global setting. Enabled/Disabled overrides per host."
size="small"
fullWidth
>
<MenuItem value="inherit">Inherit Global</MenuItem>
<MenuItem value="enabled">Enabled</MenuItem>
<MenuItem value="disabled">Disabled</MenuItem>
</TextField>
<TextField
name="upstream_dns_resolution_family"
label="Address Family Preference"
select
defaultValue={family}
helperText="Both resolves AAAA + A with IPv6 preferred ordering."
size="small"
fullWidth
>
<MenuItem value="inherit">Inherit Global</MenuItem>
<MenuItem value="both">Both (Prefer IPv6)</MenuItem>
<MenuItem value="ipv6">IPv6 only</MenuItem>
<MenuItem value="ipv4">IPv4 only</MenuItem>
</TextField>
<Alert severity="info">
When enabled, hostname upstreams are resolved during config apply and written to Caddy as concrete IP dials. If this handler has
multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those HTTPS upstreams to avoid SNI mismatch.
<div className={cn(
"overflow-hidden transition-all duration-200",
expanded ? "max-h-[600px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-4">
<div>
<label className="text-sm font-medium mb-1 block">Resolution Mode</label>
<input type="hidden" name="upstream_dns_resolution_mode" value={currentMode} />
<Select value={currentMode} onValueChange={(v) => setCurrentMode(v as ResolutionMode)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit Global</SelectItem>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Inherit uses the global setting. Enabled/Disabled overrides per host.
</p>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Address Family Preference</label>
<input type="hidden" name="upstream_dns_resolution_family" value={currentFamily} />
<Select value={currentFamily} onValueChange={(v) => setCurrentFamily(v as FamilyMode)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit Global</SelectItem>
<SelectItem value="both">Both (Prefer IPv6)</SelectItem>
<SelectItem value="ipv6">IPv6 only</SelectItem>
<SelectItem value="ipv4">IPv4 only</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">Both resolves AAAA + A with IPv6 preferred ordering.</p>
</div>
<Alert>
<AlertDescription>
When enabled, hostname upstreams are resolved during config apply and written to Caddy as concrete IP dials. If this handler has
multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those HTTPS upstreams to avoid SNI mismatch.
</AlertDescription>
</Alert>
</Stack>
</Collapse>
</Stack>
</Box>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,9 @@
import { Box, Button, IconButton, Stack, TextField, Tooltip, Typography, Autocomplete } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import RemoveCircleIcon from "@mui/icons-material/RemoveCircle";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MinusCircle, Plus } from "lucide-react";
import { useState } from "react";
const PROTOCOL_OPTIONS = ["http://", "https://"];
type UpstreamEntry = {
protocol: string;
address: string;
@@ -33,7 +32,7 @@ export function UpstreamInput({
const [entries, setEntries] = useState<UpstreamEntry[]>(initialEntries);
const handleProtocolChange = (index: number, newProtocol: string | null) => {
const handleProtocolChange = (index: number, newProtocol: string) => {
const updated = [...entries];
updated[index].protocol = newProtocol || "http://";
setEntries(updated);
@@ -69,69 +68,56 @@ export function UpstreamInput({
.join("\n");
return (
<Box>
<div>
<input type="hidden" name={name} value={serializedValue} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Upstreams
</Typography>
<Stack spacing={1.5}>
<p className="text-sm text-muted-foreground mb-1">Upstreams</p>
<div className="flex flex-col gap-3">
{entries.map((entry, index) => (
<Stack key={index} direction="row" spacing={1} alignItems="flex-start">
<Autocomplete
freeSolo
options={PROTOCOL_OPTIONS}
value={entry.protocol}
onChange={(_, newValue) => handleProtocolChange(index, newValue)}
onInputChange={(_, newInputValue) => {
if (newInputValue) {
handleProtocolChange(index, newInputValue);
}
}}
disableClearable
sx={{ width: 140 }}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="http://"
/>
)}
/>
<TextField
<div key={index} className="flex items-start gap-2">
<Select value={entry.protocol} onValueChange={(val) => handleProtocolChange(index, val)}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http://">http://</SelectItem>
<SelectItem value="https://">https://</SelectItem>
</SelectContent>
</Select>
<Input
value={entry.address}
onChange={(e) => handleAddressChange(index, e.target.value)}
placeholder="10.0.0.5:8080"
size="small"
fullWidth
className="flex-1"
required={index === 0}
/>
<Tooltip title={entries.length === 1 ? "At least one upstream required" : "Remove upstream"}>
<span>
<IconButton
size="small"
onClick={() => handleRemove(index)}
disabled={entries.length === 1}
color="error"
sx={{ mt: 0.5 }}
>
<RemoveCircleIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
<span title={entries.length === 1 ? "At least one upstream required" : "Remove upstream"}>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(index)}
disabled={entries.length === 1}
className="text-destructive hover:text-destructive mt-0.5"
>
<MinusCircle className="h-4 w-4" />
</Button>
</span>
</div>
))}
<Button
startIcon={<AddIcon />}
type="button"
variant="ghost"
size="sm"
onClick={handleAdd}
size="small"
sx={{ alignSelf: "flex-start" }}
className="self-start"
>
<Plus className="h-4 w-4 mr-1" />
Add Upstream
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
</div>
<span className="text-xs text-muted-foreground mt-0.5 block">
Backend servers to proxy requests to
</Typography>
</Box>
</span>
</div>
);
}

View File

@@ -1,22 +1,13 @@
"use client";
import {
Box,
Button,
Checkbox,
Collapse,
Divider,
FormControlLabel,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import GppBadIcon from "@mui/icons-material/GppBad";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { ChevronDown, ClipboardCopy, ShieldOff } from "lucide-react";
import { useState } from "react";
import { type WafHostConfig } from "@/src/lib/models/proxy-hosts";
import { type WafHostConfig } from "@/lib/models/proxy-hosts";
import { WafRuleExclusions } from "./WafRuleExclusions";
type WafMode = "merge" | "override";
@@ -45,16 +36,7 @@ export function WafFields({ value, showModeSelector = true }: Props) {
const [showTemplates, setShowTemplates] = useState(false);
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "error.main",
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "rgba(211,47,47,0.06)" : "rgba(211,47,47,0.04)",
p: 2,
}}
>
<div className="rounded-lg border border-destructive bg-destructive/5 p-4">
<input type="hidden" name="waf_present" value="1" />
<input type="hidden" name="waf_enabled" value={enabled ? "on" : ""} />
<input type="hidden" name="waf_mode" value={wafMode} />
@@ -63,211 +45,158 @@ export function WafFields({ value, showModeSelector = true }: Props) {
<input type="hidden" name="waf_custom_directives" value={customDirectives} />
{/* Header */}
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
<Stack direction="row" alignItems="flex-start" spacing={1.5} flex={1} minWidth={0}>
<Box
sx={{
mt: 0.25,
width: 32,
height: 32,
borderRadius: 1.5,
bgcolor: "error.main",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<GppBadIcon sx={{ fontSize: 18, color: "#fff" }} />
</Box>
<Box minWidth={0}>
<Typography variant="subtitle1" fontWeight={700} lineHeight={1.3}>
Web Application Firewall
</Typography>
<Typography variant="body2" color="text.secondary" mt={0.25}>
<div className="flex flex-row items-start justify-between gap-2">
<div className="flex flex-row items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5 w-8 h-8 rounded-xl bg-destructive flex items-center justify-center shrink-0">
<ShieldOff className="h-4 w-4 text-white" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold leading-snug">Web Application Firewall</p>
<p className="text-sm text-muted-foreground mt-0.5">
Inspect and block malicious requests via Coraza / OWASP CRS
</Typography>
</Box>
</Stack>
</p>
</div>
</div>
<Switch
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
sx={{ flexShrink: 0 }}
onCheckedChange={setEnabled}
className="shrink-0"
/>
</Stack>
</div>
{/* Expanded content */}
<Collapse in={enabled} timeout="auto" unmountOnExit>
<Box mt={2}>
{/* Override mode selector */}
{showModeSelector && (
<>
<Stack direction="row" spacing={1}>
{(["merge", "override"] as WafMode[]).map((v) => (
<Box
key={v}
onClick={() => setWafMode(v)}
sx={{
flex: 1,
py: 0.75,
px: 1.5,
borderRadius: 1.5,
border: "1.5px solid",
borderColor: wafMode === v ? "error.main" : "divider",
bgcolor:
wafMode === v
? (theme) =>
theme.palette.mode === "dark"
? "rgba(211,47,47,0.12)"
: "rgba(211,47,47,0.08)"
: "transparent",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s ease",
userSelect: "none",
"&:hover": {
borderColor: wafMode === v ? "error.main" : "text.disabled",
},
}}
>
<Typography
variant="body2"
fontWeight={wafMode === v ? 600 : 400}
color={wafMode === v ? "error.main" : "text.secondary"}
sx={{ transition: "all 0.15s ease" }}
>
{v === "merge" ? "Merge with global" : "Override global"}
</Typography>
</Box>
))}
</Stack>
<Divider sx={{ mt: 2, mb: 2 }} />
</>
)}
{!showModeSelector && <Divider sx={{ mb: 2 }} />}
{/* Engine mode */}
<Typography
variant="caption"
color="text.secondary"
fontWeight={600}
sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}
>
Engine Mode
</Typography>
<Stack direction="row" spacing={1} mt={0.75}>
{(["inherit", "Off", "On"] as EngineMode[]).map((v) => (
<Box
key={v}
onClick={() => setEngineMode(v)}
sx={{
flex: 1,
py: 0.75,
px: 1,
borderRadius: 1.5,
border: "1.5px solid",
borderColor: engineMode === v ? "error.main" : "divider",
bgcolor:
engineMode === v
? (theme) =>
theme.palette.mode === "dark"
? "rgba(211,47,47,0.12)"
: "rgba(211,47,47,0.08)"
: "transparent",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s ease",
userSelect: "none",
"&:hover": {
borderColor: engineMode === v ? "error.main" : "text.disabled",
},
}}
>
<Typography
variant="body2"
fontWeight={engineMode === v ? 600 : 400}
color={engineMode === v ? "error.main" : "text.secondary"}
sx={{ transition: "all 0.15s ease", fontSize: "0.8rem" }}
<div className={cn(
"overflow-hidden transition-all duration-200",
enabled ? "max-h-[2000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
)}>
{/* Override mode selector */}
{showModeSelector && (
<>
<div className="flex gap-2">
{(["merge", "override"] as WafMode[]).map((v) => (
<div
key={v}
onClick={() => setWafMode(v)}
className={cn(
"flex-1 py-2 px-3 rounded-xl border-[1.5px] cursor-pointer text-center transition-all duration-150 select-none",
wafMode === v
? "border-destructive bg-destructive/10"
: "border-border hover:border-muted-foreground"
)}
>
{v === "inherit" ? "Global default" : v}
</Typography>
</Box>
))}
</Stack>
<p className={cn(
"text-sm transition-all duration-150",
wafMode === v ? "font-semibold text-destructive" : "font-normal text-muted-foreground"
)}>
{v === "merge" ? "Merge with global" : "Override global"}
</p>
</div>
))}
</div>
<div className="border-t border-border mt-4 mb-4" />
</>
)}
{!showModeSelector && <div className="border-t border-border mb-4" />}
<Divider sx={{ mt: 2, mb: 1.5 }} />
{/* OWASP CRS */}
<FormControlLabel
control={
<Checkbox
checked={loadCrs}
onChange={(_, checked) => setLoadCrs(checked)}
size="small"
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={500}>
Load OWASP Core Rule Set
</Typography>
<Typography variant="caption" color="text.secondary">
Covers SQLi, XSS, LFI, RCE and hundreds of other attack patterns
</Typography>
</Box>
}
/>
{/* Excluded rule IDs */}
<Box mt={1.5}>
<WafRuleExclusions value={value?.excluded_rule_ids} />
</Box>
{/* Custom directives */}
<Box mt={1.5}>
<TextField
label="Custom SecLang Directives"
multiline
minRows={3}
maxRows={10}
value={customDirectives}
onChange={(e) => setCustomDirectives(e.target.value)}
placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`}
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
helperText="ModSecurity SecLang syntax. Appended after OWASP CRS if enabled."
fullWidth
/>
</Box>
{/* Quick Templates */}
<Box mt={0.5}>
<Button
size="small"
endIcon={<ExpandMoreIcon sx={{ transform: showTemplates ? "rotate(180deg)" : "none", transition: "transform 0.2s" }} />}
onClick={() => setShowTemplates((v) => !v)}
sx={{ color: "text.secondary", textTransform: "none", px: 0 }}
{/* Engine mode */}
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
Engine Mode
</span>
<div className="flex gap-2 mt-1.5">
{(["inherit", "Off", "On"] as EngineMode[]).map((v) => (
<div
key={v}
onClick={() => setEngineMode(v)}
className={cn(
"flex-1 py-2 px-2 rounded-xl border-[1.5px] cursor-pointer text-center transition-all duration-150 select-none",
engineMode === v
? "border-destructive bg-destructive/10"
: "border-border hover:border-muted-foreground"
)}
>
Quick Templates
</Button>
<Collapse in={showTemplates}>
<Stack spacing={0.75} mt={0.75}>
{QUICK_TEMPLATES.map((t) => (
<Button
key={t.label}
size="small"
variant="outlined"
startIcon={<ContentCopyIcon fontSize="inherit" />}
onClick={() => setCustomDirectives((prev) => prev ? `${prev}\n${t.snippet}` : t.snippet)}
sx={{ justifyContent: "flex-start", textTransform: "none", fontFamily: "monospace", fontSize: "0.72rem" }}
>
{t.label}
</Button>
))}
</Stack>
</Collapse>
</Box>
</Box>
</Collapse>
</Box>
<p className={cn(
"text-[0.8rem] transition-all duration-150",
engineMode === v ? "font-semibold text-destructive" : "font-normal text-muted-foreground"
)}>
{v === "inherit" ? "Global default" : v}
</p>
</div>
))}
</div>
<div className="border-t border-border mt-4 mb-3" />
{/* OWASP CRS */}
<div className="flex items-start gap-2">
<Checkbox
id="waf-load-crs"
checked={loadCrs}
onCheckedChange={(checked) => setLoadCrs(!!checked)}
/>
<label htmlFor="waf-load-crs" className="cursor-pointer">
<p className="text-sm font-medium">Load OWASP Core Rule Set</p>
<span className="text-xs text-muted-foreground">
Covers SQLi, XSS, LFI, RCE and hundreds of other attack patterns
</span>
</label>
</div>
{/* Excluded rule IDs */}
<div className="mt-4">
<WafRuleExclusions value={value?.excluded_rule_ids} />
</div>
{/* Custom directives */}
<div className="mt-4">
<Textarea
placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`}
value={customDirectives}
onChange={(e) => setCustomDirectives(e.target.value)}
className="font-mono text-xs min-h-[80px]"
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
Custom SecLang Directives ModSecurity SecLang syntax. Appended after OWASP CRS if enabled.
</p>
</div>
{/* Quick Templates */}
<div className="mt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowTemplates((v) => !v)}
className="text-muted-foreground px-0 text-sm"
>
Quick Templates
<ChevronDown className={cn(
"h-4 w-4 ml-1 transition-transform duration-200",
showTemplates && "rotate-180"
)} />
</Button>
<div className={cn(
"overflow-hidden transition-all duration-200",
showTemplates ? "max-h-[500px] opacity-100 mt-2" : "max-h-0 opacity-0 pointer-events-none"
)}>
<div className="flex flex-col gap-1.5">
{QUICK_TEMPLATES.map((t) => (
<Button
key={t.label}
type="button"
size="sm"
variant="outline"
onClick={() => setCustomDirectives((prev) => prev ? `${prev}\n${t.snippet}` : t.snippet)}
className="justify-start font-mono text-[0.72rem]"
>
<ClipboardCopy className="h-3 w-3 mr-1 shrink-0" />
{t.label}
</Button>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
"use client";
import { Box, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, X } from "lucide-react";
import { useState } from "react";
type Props = {
@@ -25,39 +27,45 @@ export function WafRuleExclusions({ value }: Props) {
}
return (
<Box>
<div>
<input type="hidden" name="waf_excluded_rule_ids" value={JSON.stringify(ids)} />
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
Excluded Rule IDs
</Typography>
<Typography variant="caption" color="text.secondary" display="block" mb={0.75}>
</span>
<span className="text-xs text-muted-foreground block mb-2">
Rules listed here are disabled via <code>SecRuleRemoveById</code>
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" gap={0.75} mb={ids.length ? 1 : 0}>
{ids.map((id) => (
<Chip
key={id}
label={id}
size="small"
onDelete={() => removeId(id)}
sx={{ fontFamily: "monospace", fontSize: "0.75rem" }}
/>
))}
</Stack>
<Stack direction="row" spacing={1} alignItems="center" sx={{ maxWidth: 260 }}>
<TextField
size="small"
label="Rule ID"
</span>
{ids.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{ids.map((id) => (
<Badge key={id} variant="secondary" className="gap-1 pr-1 font-mono text-xs">
{id}
<button
type="button"
onClick={() => removeId(id)}
className="rounded-full hover:bg-destructive/20 p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex items-center gap-2 max-w-[260px]">
<Input
size={1}
placeholder="Rule ID"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addId(); } }}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
sx={{ flex: 1 }}
inputMode="numeric"
pattern="[0-9]*"
className="flex-1 h-8 text-sm"
/>
<IconButton size="small" onClick={addId} color="primary">
<AddIcon fontSize="small" />
</IconButton>
</Stack>
</Box>
<Button type="button" size="icon" variant="ghost" onClick={addId} className="h-8 w-8">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
);
}