diff --git a/README.md b/README.md index 0333f6cb..f27bcac4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Web interface for managing [Caddy Server](https://caddyserver.com/) reverse prox [Report Bug](https://github.com/fuomag9/caddy-proxy-manager/issues) • [Request Feature](https://github.com/fuomag9/caddy-proxy-manager/issues) -Dashboard screenshot +Dashboard screenshot ## Overview diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index 4ec44460..20f9cee4 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -1,9 +1,34 @@ "use client"; -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { Avatar, Box, Button, Divider, Stack, Typography } from "@mui/material"; +import { usePathname, useRouter } from "next/navigation"; +import { + Avatar, + Box, + Button, + Divider, + Drawer, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Stack, + Typography, + useTheme, + useMediaQuery, + IconButton +} from "@mui/material"; +import MenuIcon from "@mui/icons-material/Menu"; +import DashboardIcon from "@mui/icons-material/Dashboard"; +import DnsIcon from "@mui/icons-material/Dns"; +import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; +import ReportProblemIcon from "@mui/icons-material/ReportProblem"; +import SecurityIcon from "@mui/icons-material/Security"; +import ShieldIcon from "@mui/icons-material/Shield"; +import SettingsIcon from "@mui/icons-material/Settings"; +import HistoryIcon from "@mui/icons-material/History"; +import LogoutIcon from "@mui/icons-material/Logout"; type User = { id: string; @@ -13,248 +38,182 @@ type User = { }; const NAV_ITEMS = [ - { href: "/", label: "Overview" }, - { href: "/proxy-hosts", label: "Proxy Hosts" }, - { href: "/redirects", label: "Redirects" }, - { href: "/dead-hosts", label: "Dead Hosts" }, - { href: "/access-lists", label: "Access Lists" }, - { href: "/certificates", label: "Certificates" }, - { href: "/settings", label: "Settings" }, - { href: "/audit-log", label: "Audit Log" } + { href: "/", label: "Overview", icon: DashboardIcon }, + { href: "/proxy-hosts", label: "Proxy Hosts", icon: DnsIcon }, + { href: "/redirects", label: "Redirects", icon: SwapHorizIcon }, + { href: "/dead-hosts", label: "Dead Hosts", icon: ReportProblemIcon }, + { href: "/access-lists", label: "Access Lists", icon: SecurityIcon }, + { href: "/certificates", label: "Certificates", icon: ShieldIcon }, + { href: "/settings", label: "Settings", icon: SettingsIcon }, + { href: "/audit-log", label: "Audit Log", icon: HistoryIcon } ] as const; -// Stable background styles defined outside component to prevent regeneration -const MAIN_BACKGROUND = - "radial-gradient(circle at 12% -20%, rgba(99, 102, 241, 0.28), transparent 45%), radial-gradient(circle at 88% 8%, rgba(45, 212, 191, 0.24), transparent 46%), linear-gradient(160deg, rgba(2, 3, 9, 1) 0%, rgba(4, 10, 22, 1) 40%, rgba(2, 6, 18, 1) 100%)"; - -const OVERLAY_BACKGROUND = - "radial-gradient(circle at 18% -12%, rgba(56, 189, 248, 0.18), transparent 42%), radial-gradient(circle at 86% 0%, rgba(168, 85, 247, 0.15), transparent 45%)"; +const DRAWER_WIDTH = 260; export default function DashboardLayoutClient({ user, children }: { user: User; children: ReactNode }) { + const [mobileOpen, setMobileOpen] = useState(false); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); const pathname = usePathname(); + const router = useRouter(); - return ( - - - - - { + setMobileOpen(!mobileOpen); + }; + + const drawerContent = ( + + + + Caddy Proxy + + + Manager + + + + + {NAV_ITEMS.map((item) => { + const selected = pathname === item.href; + const Icon = item.icon; + return ( + isMobile && setMobileOpen(false)} sx={{ - fontWeight: 600, - fontSize: "1.125rem", - letterSpacing: "-0.02em", - color: "rgba(255, 255, 255, 0.95)" - }} - > - Caddy Proxy - - - Manager - - - - - - - {NAV_ITEMS.map((item) => { - const selected = pathname === item.href; - return ( - - - {item.label} - - - ); - })} - - - - - - - - {(user.name ?? user.email ?? "A").slice(0, 2).toUpperCase()} - - - - {user.name ?? user.email ?? "Admin"} - - - Administrator - - - - -
- -
-
+ + + + + + ); + })} + + + + + { + if (isMobile) setMobileOpen(false); + router.push("/profile"); + }} + sx={{ + gap: 2, + px: 1, + mb: 2, + py: 1, + borderRadius: 1, + color: "text.primary" + }} + > + + {(user.name?.[0] || "U").toUpperCase()} + + + + {user.name || "Administrator"} + + + {user.email} + + + +
+ +
+
+ + ); + + return ( + + {isMobile && ( + + + + )} + + + + {drawerContent} + - - - {children} - + + {children} + +
); } diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx index 27cff26a..8885f025 100644 --- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx +++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx @@ -1,51 +1,19 @@ "use client"; -import { useMemo, useState, useEffect } from "react"; -import { - Alert, - Autocomplete, - Box, - Button, - Card, - CardContent, - Checkbox, - Chip, - Collapse, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - IconButton, - InputAdornment, - MenuItem, - Stack, - Switch, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Typography, - Tooltip -} from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; +import { useMemo, useState } from "react"; +import { IconButton, Stack, Switch, Tooltip, Typography } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; -import CloseIcon from "@mui/icons-material/Close"; -import SearchIcon from "@mui/icons-material/Search"; -import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; -import PauseRoundedIcon from "@mui/icons-material/PauseRounded"; -import { useFormState } from "react-dom"; import type { AccessList } from "@/src/lib/models/access-lists"; import type { Certificate } from "@/src/lib/models/certificates"; import type { ProxyHost } from "@/src/lib/models/proxy-hosts"; import type { AuthentikSettings } from "@/src/lib/settings"; -import { INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions"; -import { createProxyHostAction, deleteProxyHostAction, updateProxyHostAction, toggleProxyHostAction } from "./actions"; +import { toggleProxyHostAction } from "./actions"; +import { PageHeader } from "@/src/components/ui/PageHeader"; +import { SearchField } from "@/src/components/ui/SearchField"; +import { DataTable } from "@/src/components/ui/DataTable"; +import { StatusChip } from "@/src/components/ui/StatusChip"; +import { CreateHostDialog, EditHostDialog, DeleteHostDialog } from "@/src/components/proxy-hosts/HostDialogs"; type Props = { hosts: ProxyHost[]; @@ -54,26 +22,8 @@ type Props = { authentikDefaults: AuthentikSettings | null; }; -const AUTHENTIK_DEFAULT_HEADERS = [ - "X-Authentik-Username", - "X-Authentik-Groups", - "X-Authentik-Entitlements", - "X-Authentik-Email", - "X-Authentik-Name", - "X-Authentik-Uid", - "X-Authentik-Jwt", - "X-Authentik-Meta-Jwks", - "X-Authentik-Meta-Outpost", - "X-Authentik-Meta-Provider", - "X-Authentik-Meta-App", - "X-Authentik-Meta-Version" -]; - -const AUTHENTIK_DEFAULT_TRUSTED_PROXIES = ["private_ranges"]; - export default function ProxyHostsClient({ hosts, certificates, accessLists, authentikDefaults }: Props) { const [createOpen, setCreateOpen] = useState(false); - const [createDialogKey, setCreateDialogKey] = useState(0); const [editHost, setEditHost] = useState(null); const [deleteHost, setDeleteHost] = useState(null); const [searchTerm, setSearchTerm] = useState(""); @@ -85,14 +35,11 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut return hosts.filter((host) => { // Search in name if (host.name.toLowerCase().includes(search)) return true; - // Search in domains if (host.domains.some(domain => domain.toLowerCase().includes(search))) return true; - // Search in upstreams if (host.upstreams.some(upstream => upstream.toLowerCase().includes(search))) return true; - // Search in certificate name const certificate = host.certificate_id ? certificates.find(c => c.id === host.certificate_id) : null; @@ -107,176 +54,100 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut await toggleProxyHostAction(id, enabled); }; - const handleOpenCreate = () => { - setCreateDialogKey(prev => prev + 1); - setCreateOpen(true); - }; - - return ( - - - - - Proxy Hosts - - - Define HTTP(S) reverse proxies orchestrated by Caddy with automated certificates. + const columns = [ + { + id: "name", + label: "Name", + render: (host: ProxyHost) => ( + + + {host.name} - - + ) + }, + { + id: "domains", + label: "Domains", + render: (host: ProxyHost) => ( + + + {host.domains[0]} + {host.domains.length > 1 && ` +${host.domains.length - 1} more`} + + + ) + }, + { + id: "upstreams", + label: "Upstreams", + render: (host: ProxyHost) => ( + + {host.upstreams[0]} + {host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`} + + ) + }, + { + id: "status", + label: "Status", + render: (host: ProxyHost) => ( + + ) + }, + { + id: "actions", + label: "Actions", + align: "right" as const, + width: 150, + render: (host: ProxyHost) => ( + + handleToggleEnabled(host.id, e.target.checked)} + size="small" + color="success" + /> + + setEditHost(host)} color="primary"> + + + + + setDeleteHost(host)} color="error"> + + + + + ) + } + ]; - setSearchTerm(e.target.value)} - slotProps={{ - input: { - startAdornment: - } - }} - sx={{ - maxWidth: 500, - "& .MuiOutlinedInput-root": { - bgcolor: "rgba(20, 20, 22, 0.6)", - "&:hover": { - bgcolor: "rgba(20, 20, 22, 0.8)" - } - } + return ( + + setCreateOpen(true) }} /> - - - - - Name - Domains - Upstreams - Certificate - Status - Actions - - - - {filteredHosts.length === 0 ? ( - - - {searchTerm ? "No proxy hosts match your search." : "No proxy hosts configured. Click \"Create Host\" to add one."} - - - ) : ( - filteredHosts.map((host) => { - const certificate = host.certificate_id - ? certificates.find(c => c.id === host.certificate_id) - : null; - const certName = certificate?.name ?? "Managed by Caddy (Auto)"; + setSearchTerm(e.target.value)} + placeholder="Search hosts..." + /> - return ( - - - - {host.name} - - - - - {host.domains.slice(0, 2).join(", ")} - {host.domains.length > 2 && ` +${host.domains.length - 2} more`} - - - - - {host.upstreams.slice(0, 2).join(", ")} - {host.upstreams.length > 2 && ` +${host.upstreams.length - 2} more`} - - - - - {certName} - - - - handleToggleEnabled(host.id, e.target.checked)} - size="small" - sx={{ - "& .MuiSwitch-switchBase.Mui-checked": { - color: "rgba(34, 197, 94, 1)" - }, - "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": { - backgroundColor: "rgba(34, 197, 94, 0.5)" - } - }} - /> - - - - - setEditHost(host)} - sx={{ - color: "rgba(99, 102, 241, 0.8)", - "&:hover": { bgcolor: "rgba(99, 102, 241, 0.1)" } - }} - > - - - - - setDeleteHost(host)} - sx={{ - color: "rgba(239, 68, 68, 0.8)", - "&:hover": { bgcolor: "rgba(239, 68, 68, 0.1)" } - }} - > - - - - - - - ); - }) - )} - -
-
+ setCreateOpen(false)} certificates={certificates} @@ -304,813 +175,3 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut
); } - -function CreateHostDialog({ - open, - onClose, - certificates, - accessLists, - authentikDefaults -}: { - open: boolean; - onClose: () => void; - certificates: Certificate[]; - accessLists: AccessList[]; - authentikDefaults: AuthentikSettings | null; -}) { - const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE); - - useEffect(() => { - if (state.status === "success") { - // revalidatePath in server action already handles the refresh - setTimeout(onClose, 1000); - } - }, [state.status, onClose]); - - return ( - - - - Create Proxy Host - - - - - - - - {state.status !== "idle" && state.message && ( - - {state.message} - - )} - - - - - - Managed by Caddy (Auto) - {certificates.map((cert) => ( - - {cert.name} - - ))} - - - None - {accessLists.map((list) => ( - - {list.name} - - ))} - - - - - - - - - - - - ); -} - -function EditHostDialog({ - open, - host, - onClose, - certificates, - accessLists -}: { - open: boolean; - host: ProxyHost; - onClose: () => void; - certificates: Certificate[]; - accessLists: AccessList[]; -}) { - const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE); - - useEffect(() => { - if (state.status === "success") { - // revalidatePath in server action already handles the refresh - setTimeout(onClose, 1000); - } - }, [state.status, onClose]); - - return ( - - - - Edit Proxy Host - - - - - - - - {state.status !== "idle" && state.message && ( - - {state.message} - - )} - - - - - - Managed by Caddy (Auto) - {certificates.map((cert) => ( - - {cert.name} - - ))} - - - None - {accessLists.map((list) => ( - - {list.name} - - ))} - - - - - - - - - - - - ); -} - -function DeleteHostDialog({ - open, - host, - onClose -}: { - open: boolean; - host: ProxyHost; - onClose: () => void; -}) { - const [state, formAction] = useFormState(deleteProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE); - - useEffect(() => { - if (state.status === "success") { - // revalidatePath in server action already handles the refresh - setTimeout(onClose, 1000); - } - }, [state.status, onClose]); - - return ( - - - - Delete Proxy Host - - - - - - - - {state.status !== "idle" && state.message && ( - - {state.message} - - )} - - Are you sure you want to delete the proxy host {host.name}? - - - This will remove the configuration for: - - - - • Domains: {host.domains.join(", ")} - - - • Upstreams: {host.upstreams.join(", ")} - - - - This action cannot be undone. - - - - - -
- -
-
-
- ); -} - -function AuthentikFields({ - authentik, - defaults -}: { - authentik?: ProxyHost["authentik"] | null; - defaults?: AuthentikSettings | null; -}) { - const initial = authentik ?? null; - const [enabled, setEnabled] = useState(initial?.enabled ?? false); - - const copyHeadersValue = - initial && initial.copyHeaders.length > 0 ? initial.copyHeaders.join("\n") : AUTHENTIK_DEFAULT_HEADERS.join("\n"); - const trustedProxiesValue = - initial && initial.trustedProxies.length > 0 - ? initial.trustedProxies.join("\n") - : AUTHENTIK_DEFAULT_TRUSTED_PROXIES.join("\n"); - const setHostHeaderDefault = initial?.setOutpostHostHeader ?? true; - - return ( - - - - - - - - Authentik Forward Auth - - - Proxy authentication via Authentik outpost - - - setEnabled(checked)} - /> - - - - - - - - - - - - - - - - ); -} - -function HiddenCheckboxField({ - name, - defaultChecked, - label, - disabled, - helperText -}: { - name: string; - defaultChecked: boolean; - label: string; - disabled?: boolean; - helperText?: string; -}) { - return ( - - - - } - label={{label}} - disabled={disabled} - /> - {helperText && ( - - {helperText} - - )} - - ); -} - -type ToggleSetting = { - name: string; - label: string; - description: string; - defaultChecked: boolean; - color?: "success" | "warning" | "default"; -}; - -function SettingsToggles({ - hstsSubdomains = false, - skipHttpsValidation = false, - enabled = true -}: { - hstsSubdomains?: boolean; - skipHttpsValidation?: boolean; - enabled?: boolean; -}) { - const [values, setValues] = useState({ - hsts_subdomains: hstsSubdomains, - skip_https_hostname_validation: skipHttpsValidation, - enabled: enabled - }); - - const handleChange = (name: keyof typeof values) => (event: React.ChangeEvent) => { - setValues(prev => ({ ...prev, [name]: event.target.checked })); - }; - - const toggleEnabled = () => { - setValues(prev => ({ ...prev, enabled: !prev.enabled })); - }; - - const settings: ToggleSetting[] = [ - { - name: "hsts_subdomains", - label: "HSTS Subdomains", - description: "Include subdomains in the Strict-Transport-Security header", - defaultChecked: values.hsts_subdomains, - color: "default" - }, - { - 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: "warning" - } - ]; - - return ( - - {/* Prominent Enabled/Paused Control */} - - - - - - {values.enabled ? ( - - ) : ( - - )} - - - - {values.enabled ? "Active" : "Paused"} - - - {values.enabled - ? "This proxy host is enabled and routing traffic" - : "This proxy host is paused and not routing traffic"} - - - - Click to {values.enabled ? "pause" : "activate"} - - - - - {/* Other Options */} - - - - Advanced Options - - - }> - {settings.map((setting) => ( - - - - - - {setting.label} - - - {setting.description} - - - - - - ))} - - - - ); -} - -const PROTOCOL_OPTIONS = ["http://", "https://"]; - -type UpstreamEntry = { - protocol: string; - address: string; -}; - -function parseUpstream(upstream: string): UpstreamEntry { - if (upstream.startsWith("https://")) { - return { protocol: "https://", address: upstream.slice(8) }; - } - if (upstream.startsWith("http://")) { - return { protocol: "http://", address: upstream.slice(7) }; - } - // Default to http:// if no protocol specified - return { protocol: "http://", address: upstream }; -} - -function UpstreamInput({ - defaultUpstreams = [], - name = "upstreams" -}: { - defaultUpstreams?: string[]; - name?: string; -}) { - const initialEntries: UpstreamEntry[] = defaultUpstreams.length > 0 - ? defaultUpstreams.map(parseUpstream) - : [{ protocol: "http://", address: "" }]; - - const [entries, setEntries] = useState(initialEntries); - - const handleProtocolChange = (index: number, newProtocol: string | null) => { - const updated = [...entries]; - updated[index].protocol = newProtocol || "http://"; - setEntries(updated); - }; - - const handleAddressChange = (index: number, newAddress: string) => { - const updated = [...entries]; - updated[index].address = newAddress; - setEntries(updated); - }; - - const handleAdd = () => { - setEntries([...entries, { protocol: "http://", address: "" }]); - }; - - const handleRemove = (index: number) => { - if (entries.length === 1) return; - setEntries(entries.filter((_, i) => i !== index)); - }; - - // Serialize entries to a single string for form submission - // Use newline as separator - addresses shouldn't contain newlines - const serializedValue = entries - .filter(e => e.address.trim() !== "") - .map(e => `${e.protocol}${e.address.trim()}`) - .join("\n"); - - return ( - - - - Upstreams - - - {entries.map((entry, index) => ( - - handleProtocolChange(index, newValue)} - onInputChange={(_, newInputValue) => { - if (newInputValue) { - handleProtocolChange(index, newInputValue); - } - }} - disableClearable - sx={{ width: 140 }} - renderInput={(params) => ( - - )} - /> - handleAddressChange(index, e.target.value)} - placeholder="10.0.0.5:8080" - size="small" - fullWidth - required={index === 0} - sx={{ - "& .MuiOutlinedInput-root": { - bgcolor: "rgba(20, 20, 22, 0.6)", - } - }} - /> - - - handleRemove(index)} - disabled={entries.length === 1} - sx={{ - color: entries.length === 1 ? "rgba(255, 255, 255, 0.2)" : "rgba(239, 68, 68, 0.7)", - "&:hover": { bgcolor: "rgba(239, 68, 68, 0.1)" }, - mt: 0.5 - }} - > - - - - - - ))} - - - - Backend servers to proxy requests to (supports load balancing with multiple upstreams) - - - ); -} diff --git a/app/providers.tsx b/app/providers.tsx index 67fea231..78fd2bff 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -11,25 +11,58 @@ export default function Providers({ children }: { children: ReactNode }) { palette: { mode: "dark", background: { - default: "#040507", - paper: "rgba(15, 21, 33, 0.88)" + default: "#09090b", // Zinc-950 + paper: "#18181b" // Zinc-900 }, primary: { - main: "#7f5bff" + main: "#6366f1", // Indigo-500 + light: "#818cf8", + dark: "#4f46e5", + contrastText: "#ffffff" }, secondary: { - main: "#22d3ee" + main: "#06b6d4", // Cyan-500 + light: "#22d3ee", + dark: "#0891b2", + contrastText: "#ffffff" + }, + error: { + main: "#ef4444", // Red-500 + light: "#f87171", + dark: "#dc2626" + }, + success: { + main: "#22c55e", // Green-500 + light: "#4ade80", + dark: "#16a34a" + }, + warning: { + main: "#f59e0b", // Amber-500 + light: "#fbbf24", + dark: "#d97706" + }, + info: { + main: "#3b82f6", // Blue-500 + light: "#60a5fa", + dark: "#2563eb" }, text: { - primary: "#f5f7fb", - secondary: "rgba(232, 236, 255, 0.6)" + primary: "#f4f4f5", // Zinc-100 + secondary: "#a1a1aa" // Zinc-400 } }, typography: { fontFamily: ['"Inter"', '"Segoe UI"', "Roboto", "sans-serif"].join(","), h4: { fontWeight: 700, - letterSpacing: "-0.015em" + letterSpacing: "-0.02em" + }, + h6: { + fontWeight: 600 + }, + button: { + fontWeight: 600, + textTransform: "none" } }, shape: { @@ -38,19 +71,29 @@ export default function Providers({ children }: { children: ReactNode }) { components: { MuiCssBaseline: { styleOverrides: { - body: { + root: { + backgroundColor: "#09090b", backgroundImage: - "radial-gradient(circle at top left, rgba(79, 70, 229, 0.28), transparent 45%), radial-gradient(circle at 80% 20%, rgba(14, 165, 233, 0.25), transparent 50%), linear-gradient(160deg, #020309 0%, #030710 45%, #040607 100%)" + "radial-gradient(circle at 50% 0%, rgba(99, 102, 241, 0.15), transparent 40%), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), transparent 30%)", + backgroundAttachment: "fixed" } } }, MuiButton: { + defaultProps: { + disableElevation: true + }, styleOverrides: { root: { - textTransform: "none", - borderRadius: 999, - fontWeight: 600, - paddingInline: 20 + borderRadius: 8, + padding: "8px 16px", + transition: "all 0.2s ease-in-out" + }, + contained: { + "&:hover": { + transform: "translateY(-1px)", + boxShadow: "0 4px 12px rgba(99, 102, 241, 0.3)" + } } } }, @@ -58,22 +101,56 @@ export default function Providers({ children }: { children: ReactNode }) { styleOverrides: { root: { backgroundImage: "none", - backgroundColor: "rgba(15, 21, 33, 0.9)", - border: "1px solid rgba(127, 91, 255, 0.18)", - boxShadow: "0 24px 60px rgba(0, 0, 0, 0.4)", - backdropFilter: "blur(22px)" + backgroundColor: "rgba(24, 24, 27, 0.6)", // Zinc-900 / 60% + border: "1px solid rgba(255, 255, 255, 0.08)", + backdropFilter: "blur(12px)", + transition: "all 0.3s ease", + "&:hover": { + borderColor: "rgba(255, 255, 255, 0.15)", + boxShadow: "0 12px 32px rgba(0, 0, 0, 0.4)" + } } } }, MuiPaper: { styleOverrides: { root: { - backgroundColor: "rgba(15, 21, 33, 0.92)", + backgroundImage: "none" + } + } + }, + MuiTableCell: { + styleOverrides: { + root: { + borderBottom: "1px solid rgba(255, 255, 255, 0.06)", + padding: "16px 24px" + }, + head: { + fontWeight: 600, + backgroundColor: "rgba(24, 24, 27, 0.4)", + color: "#a1a1aa", + fontSize: "0.75rem", + textTransform: "uppercase", + letterSpacing: "0.05em" + } + } + }, + MuiTableRow: { + styleOverrides: { + root: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.02)" + } + } + } + }, + MuiDialog: { + styleOverrides: { + paper: { + backgroundColor: "#18181b", + border: "1px solid rgba(255, 255, 255, 0.1)", backgroundImage: "none", - borderRadius: 16, - border: "1px solid rgba(148, 163, 184, 0.08)", - boxShadow: "0 18px 48px rgba(2, 6, 23, 0.45)", - backdropFilter: "blur(18px)" + boxShadow: "0 24px 48px rgba(0, 0, 0, 0.5)" } } }, @@ -81,39 +158,16 @@ export default function Providers({ children }: { children: ReactNode }) { styleOverrides: { root: { "& .MuiOutlinedInput-root": { - backgroundColor: "rgba(13, 18, 30, 0.75)", - borderRadius: 14, + borderRadius: 10, + backgroundColor: "rgba(9, 9, 11, 0.5)", "& fieldset": { - borderColor: "rgba(148, 163, 184, 0.2)" + borderColor: "rgba(255, 255, 255, 0.08)" }, "&:hover fieldset": { - borderColor: "rgba(127, 91, 255, 0.6)" + borderColor: "rgba(255, 255, 255, 0.2)" }, "&.Mui-focused fieldset": { - borderColor: "#7f5bff" - } - }, - "& .MuiInputLabel-root": { - color: "rgba(229, 231, 235, 0.7)" - } - } - } - }, - MuiListItemButton: { - styleOverrides: { - root: { - borderRadius: 14, - transition: "background-color 150ms ease, transform 150ms ease", - "&:hover": { - backgroundColor: "rgba(127, 91, 255, 0.1)", - transform: "translateX(2px)" - }, - "&.Mui-selected": { - background: "linear-gradient(135deg, rgba(127, 91, 255, 0.9), rgba(34, 211, 238, 0.9))", - color: "#05030a", - boxShadow: "0 12px 30px rgba(34, 211, 238, 0.24)", - "&:hover": { - background: "linear-gradient(135deg, rgba(127, 91, 255, 0.8), rgba(34, 211, 238, 0.8))" + borderColor: "#6366f1" } } } diff --git a/site/assets/screenshots/audit-log.png b/site/assets/screenshots/audit-log.png new file mode 100644 index 00000000..2fd1c6c7 Binary files /dev/null and b/site/assets/screenshots/audit-log.png differ diff --git a/site/assets/screenshots/certificates.png b/site/assets/screenshots/certificates.png index e8012e63..ddfc88eb 100644 Binary files a/site/assets/screenshots/certificates.png and b/site/assets/screenshots/certificates.png differ diff --git a/site/assets/screenshots/dashboard-main.png b/site/assets/screenshots/dashboard-main.png index a053bcd1..497b3ebe 100644 Binary files a/site/assets/screenshots/dashboard-main.png and b/site/assets/screenshots/dashboard-main.png differ diff --git a/site/assets/screenshots/proxy-editor.png b/site/assets/screenshots/proxy-editor.png index 5dd532b9..186a0c29 100644 Binary files a/site/assets/screenshots/proxy-editor.png and b/site/assets/screenshots/proxy-editor.png differ diff --git a/site/index.html b/site/index.html index 4478aa68..c860931b 100644 --- a/site/index.html +++ b/site/index.html @@ -1,162 +1,167 @@ + - - Caddy Proxy Manager - Modern Web UI for Caddy Server + + Caddy Proxy Manager - + - + - + + + - -
- -
-

Web UI for Caddy Server

-

Modern interface for Caddy's automatic HTTPS.

-

- Caddy Proxy Manager provides a web-based control panel for managing reverse proxies, - certificates, and configurations. Built with Next.js and featuring a clean dark interface. -

- -
-
- Docker - Quick deployment -
-
- Full - Audit history -
-
- Auto - TLS with ACME -
-
+ Data + GitHub +
-
-
-

Key Features

-

Streamlined Caddy management.

-

Manage your Caddy server configuration through an intuitive web interface with built-in security and audit logging.

+
+

Control Every Edge.

+

The modern, secure context for your reverse proxy. Manage Caddy with an intuitive interface, automatic HTTPS, + and detailed audit logging.

+ + -
-
-

Reverse Proxy Management

-

Configure reverse proxies, set custom headers, and manage domain routing through an easy-to-use web interface.

-
-
-

Certificate Management

-

Automatic ACME certificate provisioning with Cloudflare DNS-01 support, plus manual certificate imports for custom setups.

-
-
-

Audit Logging

-

Complete audit trail of all configuration changes with timestamps and user attribution for accountability.

-
-
-

Built-in Security

-

Strong password requirements, secure session management, rate limiting, and HSTS headers protect your installation.

-
+ +
+
+ Caddy Proxy Manager Dashboard +
-
-
-

Screenshots

-

See the interface.

+
+
+

Powerful Simplicity

+

Everything you need to manage your infrastructure, nothing you don't.

-
-
- Primary dashboard overview -
Dashboard overview showing proxy hosts, certificates, and system status.
-
-
- Certificate lifecycle manager -
Certificate management with ACME automation and manual imports.
-
-
- Proxy host editor -
Proxy host configuration with validation and testing.
-
+ +
+
+

Reverse Proxy

+

Easily configure upstream pools, custom headers, and routing rules with a type-safe editor.

+
+
+

Auto HTTPS

+

Zero-configuration TLS certificates via Let's Encrypt. Supports Cloudflare DNS-01 and HTTP-01 challenges + out of the + box.

+
+
+

Access Control

+

Secure your endpoints with basic auth, IP access lists, or valid OAuth2/OIDC sessions.

+
+
+

Audit Log

+

Every change is tracked. See who modified what and when, with full configuration diffs.

+
+
+

Modern Stack

+

Built with Next.js 16, React Server Components, and Drizzle ORM for maximum performance.

+
+
+

Docker Ready

+

Deploys in seconds with a single docker-compose file. Stateless application logic with persistent data.

+
-
-
-

Technology Stack

-

Next.js, React, Drizzle ORM, and Caddy.

-

Built with modern web technologies and integrates directly with Caddy's Admin API for real-time configuration management.

+
+
+

Designed for Reliability

+
+
+
+ Audit Log + Proxy Editor +
+
+
    +
  • +

    Caddy Powered

    +

    Uses Caddy's native Admin API for real-time configuration updates without restarts.

    +
  • +
  • +

    Type-Safe & Secure

    +

    End-to-end type safety with TypeScript. Secure session management and input validation.

    +
  • +
  • +

    SQLite Database

    +

    Self-contained data storage. Easy to backup, migrate, and maintain.

    +
  • +
  • +

    React Interface

    +

    A responsive, dark-mode first UI built with the latest React patterns for a snappy experience.

    +
  • +
+
-
    -
  • - Next.js App Router -

    Server-side rendering with React Server Components for fast page loads and smooth interactions.

    -
  • -
  • - SQLite Database -

    Drizzle ORM with SQLite for storing proxy configurations, certificates, and audit logs.

    -
  • -
  • - Caddy Admin API -

    Direct integration with Caddy's JSON API for applying configuration changes in real-time.

    -
  • -
-
-
-

Quick Setup

-

Deploy with Docker Compose.

-

Get started quickly with Docker Compose. The setup includes persistent storage and environment-based configuration.

-
-
-
 # Clone and enter
- git clone https://github.com/fuomag9/caddy-proxy-manager.git
- cd caddy-proxy-manager
+    
+
+

Deploy in Seconds

+

Get up and running with Docker Compose.

- # Configure secrets - cp .env.example .env - # ADMIN_USERNAME=your-admin - # ADMIN_PASSWORD=your-strong-password - # SESSION_SECRET=$(openssl rand -base64 32) - - # Launch the stack - docker compose up -d -
+
+ # Clone the repository + git clone https://github.com/fuomag9/caddy-proxy-manager.git + cd caddy-proxy-manager +
+ # Setup environment + cp .env.example .env +
+ # Start the stack + docker compose up -d +
-

© Caddy Proxy Manager. Crafted with intent.

+
+

© Caddy Proxy Manager. Released under the MIT License.

+
- + + \ No newline at end of file diff --git a/site/styles.css b/site/styles.css index feff34f9..a8ed34bf 100644 --- a/site/styles.css +++ b/site/styles.css @@ -1,296 +1,335 @@ :root { color-scheme: dark; - --bg: #03050a; - --bg-panel: rgba(8, 11, 20, 0.85); - --bg-panel-strong: rgba(18, 22, 33, 0.9); - --text: #f5f7fb; - --muted: #9aa8c7; - --accent: #8f8cff; - --accent-strong: #5d5bff; - --border: rgba(255, 255, 255, 0.08); - --gradient: radial-gradient(circle at top left, rgba(128, 118, 255, 0.45), transparent 55%), - radial-gradient(circle at top right, rgba(33, 212, 253, 0.35), transparent 60%); + --bg: #09090b; + --bg-subtle: #18181b; + --fg: #fafafa; + --fg-muted: #a1a1aa; + --border: #27272a; + + --primary: #4f46e5; + --primary-hover: #4338ca; + --primary-foreground: #ffffff; + + --radius: 0.75rem; + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --max-width: 1200px; } * { box-sizing: border-box; + margin: 0; + padding: 0; } body { - margin: 0; - font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: var(--bg); - color: var(--text); - min-height: 100vh; + background-color: var(--bg); + color: var(--fg); + font-family: var(--font-sans); line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + display: flex; + flex-direction: column; } -img { - max-width: 100%; - border-radius: 18px; - border: 1px solid rgba(255, 255, 255, 0.05); - background: rgba(255, 255, 255, 0.02); - object-fit: cover; -} - -h1, -h2, -h3, -h4, -h5 { - font-weight: 600; - line-height: 1.2; - margin: 0 0 0.6em; -} - -p { - margin: 0 0 1em; - color: var(--muted); -} - -code, -pre { - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; -} - -.aurora { +/* Aurora Backdrop */ +.aurora-bg { position: fixed; - inset: 0; - background-image: var(--gradient); - filter: blur(120px); - opacity: 0.8; - z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + background: + radial-gradient(circle at 15% 50%, rgba(79, 70, 229, 0.15), transparent 25%), + radial-gradient(circle at 85% 30%, rgba(147, 51, 234, 0.15), transparent 25%); + pointer-events: none; } -.hero { - position: relative; - isolation: isolate; - padding: 3rem clamp(1.25rem, 5vw, 5rem) 2rem; - overflow: hidden; +/* Layout */ +.container { + width: 100%; + max-width: var(--max-width); + margin: 0 auto; + padding: 0 1.5rem; } -.nav { +header { + border-bottom: 1px solid var(--border); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 50; + background-color: rgba(9, 9, 11, 0.8); +} + +.header-inner { display: flex; justify-content: space-between; align-items: center; - gap: 1.5rem; - padding-bottom: 2rem; - border-bottom: 1px solid var(--border); + height: 4rem; } .logo { - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; + font-weight: 700; + font-size: 1.125rem; + color: var(--fg); + text-decoration: none; + display: flex; + align-items: center; + gap: 0.5rem; } .nav-links { display: flex; - gap: 1.5rem; - flex-wrap: wrap; + gap: 2rem; } -.nav a { - color: var(--muted); +.nav-links a { + color: var(--fg-muted); text-decoration: none; - font-size: 0.95rem; - transition: color 0.3s ease; + font-size: 0.875rem; + font-weight: 500; + transition: color 0.2s; } -.nav a:hover { - color: var(--text); +.nav-links a:hover { + color: var(--fg); } -.hero-content { - max-width: 880px; - margin: 3rem auto 0; - text-align: left; +/* Hero Section */ +.hero { + padding: 8rem 0 6rem; + text-align: center; } -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.25em; - font-size: 0.78rem; - color: var(--accent); - margin-bottom: 0.8rem; +.hero h1 { + font-size: 3.5rem; + font-weight: 800; + letter-spacing: -0.025em; + line-height: 1.1; + margin-bottom: 1.5rem; + background: linear-gradient(to right, #fff, #a1a1aa); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; } -.lede { - font-size: 1.05rem; +.hero p { + font-size: 1.25rem; + color: var(--fg-muted); + max-width: 600px; + margin: 0 auto 2.5rem; } -.cta-group { +.btn-group { display: flex; + justify-content: center; gap: 1rem; - flex-wrap: wrap; - margin: 2rem 0 2.5rem; } -.cta { - padding: 0.85rem 1.8rem; - border-radius: 999px; - text-decoration: none; +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border-radius: var(--radius); font-weight: 600; - transition: transform 0.3s ease, box-shadow 0.3s ease; + font-size: 0.95rem; + text-decoration: none; + transition: all 0.2s; } -.cta.primary { +.btn-primary { + background-color: var(--fg); color: var(--bg); - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); - box-shadow: 0 10px 30px rgba(140, 137, 255, 0.45); } -.cta.secondary { - color: var(--text); +.btn-primary:hover { + opacity: 0.9; +} + +.btn-secondary { + background-color: var(--bg-subtle); + color: var(--fg); border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.05); } -.cta:hover { - transform: translateY(-3px); +.btn-secondary:hover { + background-color: var(--border); } -.metrics { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 1.5rem; - padding-top: 1rem; +/* Showcase - Screenshots */ +.showcase { + margin: 4rem 0; + perspective: 1000px; +} + +.screenshot-main { + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.05), + 0 20px 50px -10px rgba(0, 0, 0, 0.5); + overflow: hidden; + background: var(--bg-subtle); +} + +.screenshot-main img { + display: block; + width: 100%; + height: auto; +} + +/* Features Grid */ +.features { + padding: 6rem 0; border-top: 1px solid var(--border); } -.metric-value { - font-size: 1.8rem; +.section-header { + text-align: center; + margin-bottom: 4rem; +} + +.section-header h2 { + font-size: 2.25rem; + font-weight: 700; + margin-bottom: 1rem; +} + +.section-header p { + color: var(--fg-muted); + font-size: 1.125rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; +} + +.card { + background: var(--bg-subtle); + border: 1px solid var(--border); + padding: 2rem; + border-radius: var(--radius); + transition: transform 0.2s, border-color 0.2s; +} + +.card:hover { + border-color: var(--fg-muted); +} + +.card h3 { + font-size: 1.25rem; font-weight: 600; - display: block; + margin-bottom: 0.75rem; + color: var(--fg); } -main { - position: relative; - z-index: 1; - padding: 0 clamp(1.25rem, 5vw, 5rem) 5rem; +.card p { + color: var(--fg-muted); + font-size: 0.95rem; } -.panel { - margin: 3rem auto; - padding: clamp(2rem, 4vw, 3rem); - border-radius: 32px; - background: var(--bg-panel); - border: 1px solid var(--border); - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); +/* Architecture / Tech Stack */ +.tech-stack { + padding: 6rem 0; + border-top: 1px solid var(--border); } -.panel.glass { - background: rgba(8, 11, 20, 0.65); - backdrop-filter: blur(16px); -} - -.section-heading { - text-align: left; - max-width: 720px; -} - -.feature-grid, -.details-grid { - margin-top: 2rem; +.tech-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1.5rem; + grid-template-columns: repeat(2, 1fr); + gap: 4rem; + align-items: center; } -.card, -.details-grid article { - padding: 1.5rem; - background: var(--bg-panel-strong); - border-radius: 22px; - border: 1px solid var(--border); -} - -.showcase-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 1.5rem; - margin-top: 2rem; -} - -figcaption { - font-size: 0.9rem; - color: var(--muted); - margin-top: 0.75rem; -} - -.split { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - align-items: start; - gap: 2.5rem; -} - -.pillars { +.tech-list { list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 1.5rem; } -.pillars li { - padding: 1.5rem; - border-radius: 20px; - background: rgba(20, 24, 36, 0.85); - border: 1px solid var(--border); +.tech-list li { + margin-bottom: 2rem; } -.pillars span { - display: block; +.tech-list h4 { + font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; } -.code-block { - margin: 2rem 0; - border-radius: 24px; - background: rgba(10, 14, 24, 0.9); +.tech-list p { + color: var(--fg-muted); +} + +.secondary-screenshots { + display: grid; + gap: 1.5rem; +} + +.secondary-screenshots img { + border-radius: var(--radius); border: 1px solid var(--border); - overflow: hidden; + width: 100%; } -.code-block pre { - margin: 0; - padding: 2rem; - white-space: pre-wrap; - color: #d7e1ff; +/* Deployment */ +.deployment { + padding: 6rem 0; + border-top: 1px solid var(--border); + background: linear-gradient(to bottom, transparent, rgba(79, 70, 229, 0.05)); } -.panel.cta { - text-align: center; - background: linear-gradient(135deg, rgba(93, 91, 255, 0.9), rgba(33, 212, 253, 0.75)); - border: none; +.code-block { + background: #1e1e24; + padding: 1.5rem; + border-radius: var(--radius); + overflow-x: auto; + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 0.9rem; + color: #e2e8f0; + border: 1px solid var(--border); + margin-top: 2rem; + text-align: left; + max-width: 800px; + margin-left: auto; + margin-right: auto; } -.panel.cta p, -.panel.cta h2, -.panel.cta .cta { - color: #05070f; +.command { + display: block; + margin-bottom: 0.5rem; } +.comment { + color: #6b7280; +} + +/* Footer */ footer { + border-top: 1px solid var(--border); + padding: 3rem 0; text-align: center; - padding: 2rem; - color: var(--muted); + color: var(--fg-muted); + font-size: 0.875rem; } +/* Mobile */ @media (max-width: 768px) { - .nav { - flex-direction: column; + .hero h1 { + font-size: 2.5rem; + } + + .tech-grid { + grid-template-columns: 1fr; + gap: 2rem; } .nav-links { - justify-content: center; + display: none; } - - .hero-content { - text-align: left; - margin-top: 2rem; - } -} +} \ No newline at end of file diff --git a/src/components/proxy-hosts/AuthentikFields.tsx b/src/components/proxy-hosts/AuthentikFields.tsx new file mode 100644 index 00000000..5a3c0b90 --- /dev/null +++ b/src/components/proxy-hosts/AuthentikFields.tsx @@ -0,0 +1,176 @@ + +import { Box, Checkbox, Collapse, FormControlLabel, Stack, Switch, TextField, Typography } from "@mui/material"; +import { useState } from "react"; +import { AuthentikSettings } from "@/src/lib/settings"; +import { ProxyHost } from "@/src/lib/models/proxy-hosts"; + +const AUTHENTIK_DEFAULT_HEADERS = [ + "X-Authentik-Username", + "X-Authentik-Groups", + "X-Authentik-Entitlements", + "X-Authentik-Email", + "X-Authentik-Name", + "X-Authentik-Uid", + "X-Authentik-Jwt", + "X-Authentik-Meta-Jwks", + "X-Authentik-Meta-Outpost", + "X-Authentik-Meta-Provider", + "X-Authentik-Meta-App", + "X-Authentik-Meta-Version" +]; + +const AUTHENTIK_DEFAULT_TRUSTED_PROXIES = ["private_ranges"]; + +function HiddenCheckboxField({ + name, + defaultChecked, + label, + disabled, + helperText +}: { + name: string; + defaultChecked: boolean; + label: string; + disabled?: boolean; + helperText?: string; +}) { + return ( + + + + } + label={{label}} + disabled={disabled} + /> + {helperText && ( + + {helperText} + + )} + + ); +} + +export function AuthentikFields({ + authentik, + defaults +}: { + authentik?: ProxyHost["authentik"] | null; + defaults?: AuthentikSettings | null; +}) { + const initial = authentik ?? null; + const [enabled, setEnabled] = useState(initial?.enabled ?? false); + + const copyHeadersValue = + initial && initial.copyHeaders.length > 0 ? initial.copyHeaders.join("\n") : AUTHENTIK_DEFAULT_HEADERS.join("\n"); + const trustedProxiesValue = + initial && initial.trustedProxies.length > 0 + ? initial.trustedProxies.join("\n") + : AUTHENTIK_DEFAULT_TRUSTED_PROXIES.join("\n"); + const setHostHeaderDefault = initial?.setOutpostHostHeader ?? true; + + return ( + + + + + + + + Authentik Forward Auth + + + Proxy authentication via Authentik outpost + + + setEnabled(checked)} + /> + + + + + + + {/* ... other fields ... */} + + + + + + + + + + ); +} diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx new file mode 100644 index 00000000..77f06d88 --- /dev/null +++ b/src/components/proxy-hosts/HostDialogs.tsx @@ -0,0 +1,260 @@ + +import { Alert, Box, MenuItem, Stack, TextField, Typography } from "@mui/material"; +import { useFormState } from "react-dom"; +import { useEffect } from "react"; +import { + createProxyHostAction, + 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 { AuthentikFields } from "./AuthentikFields"; +import { SettingsToggles } from "./SettingsToggles"; +import { UpstreamInput } from "./UpstreamInput"; + +export function CreateHostDialog({ + open, + onClose, + certificates, + accessLists, + authentikDefaults +}: { + open: boolean; + onClose: () => void; + certificates: Certificate[]; + accessLists: AccessList[]; + authentikDefaults: AuthentikSettings | null; +}) { + const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE); + + useEffect(() => { + if (state.status === "success") { + setTimeout(onClose, 1000); + } + }, [state.status, onClose]); + + return ( + { + // Trigger generic form submit + (document.getElementById("create-host-form") as HTMLFormElement)?.requestSubmit(); + }} + > + + {state.status !== "idle" && state.message && ( + + {state.message} + + )} + + + + + + Managed by Caddy (Auto) + {certificates.map((cert) => ( + + {cert.name} + + ))} + + + None + {accessLists.map((list) => ( + + {list.name} + + ))} + + + + + + + ); +} + +export function EditHostDialog({ + open, + host, + onClose, + certificates, + accessLists +}: { + open: boolean; + host: ProxyHost; + onClose: () => void; + certificates: Certificate[]; + accessLists: AccessList[]; +}) { + const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE); + + useEffect(() => { + if (state.status === "success") { + setTimeout(onClose, 1000); + } + }, [state.status, onClose]); + + return ( + { + (document.getElementById("edit-host-form") as HTMLFormElement)?.requestSubmit(); + }} + > + + {state.status !== "idle" && state.message && ( + + {state.message} + + )} + + + + + + Managed by Caddy (Auto) + {certificates.map((cert) => ( + + {cert.name} + + ))} + + + None + {accessLists.map((list) => ( + + {list.name} + + ))} + + + + + + + ); +} + +export function DeleteHostDialog({ + open, + host, + onClose +}: { + open: boolean; + host: ProxyHost; + onClose: () => void; +}) { + const [state, formAction] = useFormState(deleteProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE); + + useEffect(() => { + if (state.status === "success") { + setTimeout(onClose, 1000); + } + }, [state.status, onClose]); + + return ( + { + (document.getElementById("delete-host-form") as HTMLFormElement)?.requestSubmit(); + }} + > + + {state.status !== "idle" && state.message && ( + + {state.message} + + )} + + Are you sure you want to delete the proxy host {host.name}? + + + This will remove the configuration for: + + + + • Domains: {host.domains.join(", ")} + + + • Upstreams: {host.upstreams.join(", ")} + + + + This action cannot be undone. + + + + ); +} diff --git a/src/components/proxy-hosts/SettingsToggles.tsx b/src/components/proxy-hosts/SettingsToggles.tsx new file mode 100644 index 00000000..561a5997 --- /dev/null +++ b/src/components/proxy-hosts/SettingsToggles.tsx @@ -0,0 +1,138 @@ + +import { Box, Stack, Switch, Typography } from "@mui/material"; +import { useState } from "react"; + +type ToggleSetting = { + name: string; + label: string; + description: string; + defaultChecked: boolean; + color?: "success" | "warning" | "default"; +}; + +type SettingsTogglesProps = { + hstsSubdomains?: boolean; + skipHttpsValidation?: boolean; + enabled?: boolean; +}; + +export function SettingsToggles({ + hstsSubdomains = false, + skipHttpsValidation = false, + enabled = true +}: SettingsTogglesProps) { + const [values, setValues] = useState({ + hsts_subdomains: hstsSubdomains, + skip_https_hostname_validation: skipHttpsValidation, + enabled: enabled + }); + + const handleChange = (name: keyof typeof values) => (event: React.ChangeEvent) => { + setValues(prev => ({ ...prev, [name]: event.target.checked })); + }; + + const handleEnabledChange = (_: React.ChangeEvent, checked: boolean) => { + setValues(prev => ({ ...prev, enabled: checked })); + }; + + const settings: ToggleSetting[] = [ + { + name: "hsts_subdomains", + label: "HSTS Subdomains", + description: "Include subdomains in the Strict-Transport-Security header", + defaultChecked: values.hsts_subdomains, + color: "default" + }, + { + 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: "warning" + } + ]; + + return ( + + + + + {/* Main Enable Switch */} + + + + {values.enabled ? "Proxy Host Enabled" : "Proxy Host Paused"} + + + {values.enabled + ? "This host is active and routing traffic" + : "This host is disabled and will not respond to requests"} + + + + + + {/* Advanced Options */} + + + + Advanced Options + + + }> + {settings.map((setting) => ( + + + + + + {setting.label} + + + {setting.description} + + + + + + ))} + + + + ); +} diff --git a/src/components/proxy-hosts/UpstreamInput.tsx b/src/components/proxy-hosts/UpstreamInput.tsx new file mode 100644 index 00000000..7a77fd97 --- /dev/null +++ b/src/components/proxy-hosts/UpstreamInput.tsx @@ -0,0 +1,129 @@ + +import { Box, Button, IconButton, Stack, TextField, Tooltip, Typography, Autocomplete, InputAdornment } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; +import { useState } from "react"; + +const PROTOCOL_OPTIONS = ["http://", "https://"]; + +type UpstreamEntry = { + protocol: string; + address: string; +}; + +function parseUpstream(upstream: string): UpstreamEntry { + if (upstream.startsWith("https://")) { + return { protocol: "https://", address: upstream.slice(8) }; + } + if (upstream.startsWith("http://")) { + return { protocol: "http://", address: upstream.slice(7) }; + } + return { protocol: "http://", address: upstream }; +} + +export function UpstreamInput({ + defaultUpstreams = [], + name = "upstreams" +}: { + defaultUpstreams?: string[]; + name?: string; +}) { + const initialEntries: UpstreamEntry[] = defaultUpstreams.length > 0 + ? defaultUpstreams.map(parseUpstream) + : [{ protocol: "http://", address: "" }]; + + const [entries, setEntries] = useState(initialEntries); + + const handleProtocolChange = (index: number, newProtocol: string | null) => { + const updated = [...entries]; + updated[index].protocol = newProtocol || "http://"; + setEntries(updated); + }; + + const handleAddressChange = (index: number, newAddress: string) => { + const updated = [...entries]; + updated[index].address = newAddress; + setEntries(updated); + }; + + const handleAdd = () => { + setEntries([...entries, { protocol: "http://", address: "" }]); + }; + + const handleRemove = (index: number) => { + if (entries.length === 1) return; + setEntries(entries.filter((_, i) => i !== index)); + }; + + const serializedValue = entries + .filter(e => e.address.trim() !== "") + .map(e => `${e.protocol}${e.address.trim()}`) + .join("\n"); + + return ( + + + + Upstreams + + + {entries.map((entry, index) => ( + + handleProtocolChange(index, newValue)} + onInputChange={(_, newInputValue) => { + if (newInputValue) { + handleProtocolChange(index, newInputValue); + } + }} + disableClearable + sx={{ width: 140 }} + renderInput={(params) => ( + + )} + /> + handleAddressChange(index, e.target.value)} + placeholder="10.0.0.5:8080" + size="small" + fullWidth + required={index === 0} + /> + + + handleRemove(index)} + disabled={entries.length === 1} + color="error" + sx={{ mt: 0.5 }} + > + + + + + + ))} + + + + Backend servers to proxy requests to + + + ); +} diff --git a/src/components/ui/AppDialog.tsx b/src/components/ui/AppDialog.tsx new file mode 100644 index 00000000..bb110360 --- /dev/null +++ b/src/components/ui/AppDialog.tsx @@ -0,0 +1,77 @@ + +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + IconButton, + Typography, + Button +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { ReactNode } from "react"; + +type AppDialogProps = { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; + maxWidth?: "xs" | "sm" | "md" | "lg" | "xl"; + actions?: ReactNode; + submitLabel?: string; + onSubmit?: () => void; + isSubmitting?: boolean; +}; + +export function AppDialog({ + open, + onClose, + title, + children, + maxWidth = "sm", + actions, + submitLabel = "Save", + onSubmit, + isSubmitting = false +}: AppDialogProps) { + return ( + + + {title} + + + + + + {children} + + + {actions ? actions : ( + <> + + {onSubmit && ( + + )} + + )} + + + ); +} diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx new file mode 100644 index 00000000..25c267ff --- /dev/null +++ b/src/components/ui/DataTable.tsx @@ -0,0 +1,76 @@ + +import { + Card, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Box +} from "@mui/material"; +import { ReactNode } from "react"; + +type Column = { + id: string; + label: string; + align?: "left" | "right" | "center"; + width?: string | number; + render?: (row: T) => ReactNode; +}; + +type DataTableProps = { + columns: Column[]; + data: T[]; + keyField: keyof T; + emptyMessage?: string; + loading?: boolean; +}; + +export function DataTable({ + columns, + data, + keyField, + emptyMessage = "No data available", + loading = false +}: DataTableProps) { + return ( + + + + + {columns.map((col) => ( + + {col.label} + + ))} + + + + {data.length === 0 && !loading ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((col) => ( + + {col.render ? col.render(row) : (row as any)[col.id]} + + ))} + + )) + )} + +
+
+ ); +} diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx new file mode 100644 index 00000000..35330f21 --- /dev/null +++ b/src/components/ui/PageHeader.tsx @@ -0,0 +1,42 @@ + +import { Box, Button, Stack, Typography } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import { ReactNode } from "react"; + +type PageHeaderProps = { + title: string; + description?: string; + action?: { + label: string; + onClick: () => void; + icon?: ReactNode; + }; +}; + +export function PageHeader({ title, description, action }: PageHeaderProps) { + return ( + + + + {title} + + {description && ( + + {description} + + )} + + {action && ( + + )} + + ); +} diff --git a/src/components/ui/SearchField.tsx b/src/components/ui/SearchField.tsx new file mode 100644 index 00000000..fd052fc0 --- /dev/null +++ b/src/components/ui/SearchField.tsx @@ -0,0 +1,37 @@ + +import { InputAdornment, TextField, TextFieldProps } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; + +export function SearchField(props: TextFieldProps) { + return ( + + + + ) + } + }} + sx={{ + maxWidth: 400, + "& .MuiOutlinedInput-root": { + bgcolor: "background.paper", + transition: "all 0.2s", + "&:hover": { + bgcolor: "action.hover" + }, + "&.Mui-focused": { + bgcolor: "background.paper", + boxShadow: "0 4px 20px rgba(0,0,0,0.2)" + } + } + }} + {...props} + /> + ); +} diff --git a/src/components/ui/StatusChip.tsx b/src/components/ui/StatusChip.tsx new file mode 100644 index 00000000..5b6854b3 --- /dev/null +++ b/src/components/ui/StatusChip.tsx @@ -0,0 +1,58 @@ + +import { Box, Typography, ChipProps } from "@mui/material"; + +type StatusType = "active" | "inactive" | "error" | "warning"; + +type StatusChipProps = { + status: StatusType; + label?: string; + sx?: any; +}; + +const STATUS_CONFIG: Record = { + active: { color: "#22c55e", label: "Active" }, // Green-500 + inactive: { color: "#71717a", label: "Paused" }, // Zinc-500 + error: { color: "#ef4444", label: "Error" }, // Red-500 + warning: { color: "#f59e0b", label: "Warning" } // Amber-500 +}; + +export function StatusChip({ status, label, sx }: StatusChipProps) { + const config = STATUS_CONFIG[status]; + const displayLabel = label || config.label; + + return ( + + + + {displayLabel} + + + ); +}