finalized UI and website for 1.0 release

This commit is contained in:
fuomag9
2026-01-15 01:16:25 +01:00
parent d3b77a394e
commit 85c7a0f8c7
19 changed files with 1743 additions and 1632 deletions

View File

@@ -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)
<img width="1525" height="873" alt="Dashboard screenshot" src="https://github.com/user-attachments/assets/297cc5b9-5185-4ce3-83ef-b5d87d16fcb4" />
<img width="100%" alt="Dashboard screenshot" src="site/assets/screenshots/dashboard-main.png" />
## Overview

View File

@@ -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 (
<Box
sx={{
display: "flex",
minHeight: "100vh",
position: "relative",
background: MAIN_BACKGROUND
}}
>
<Box
component="aside"
sx={{
width: 240,
minWidth: 240,
maxWidth: 240,
height: "100vh",
position: "fixed",
top: 0,
left: 0,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
gap: 3,
px: 2,
py: 3,
background: "rgba(20, 20, 22, 0.95)",
borderRight: "0.5px solid rgba(255, 255, 255, 0.08)",
zIndex: 1000,
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
overflowY: "auto",
overflowX: "hidden"
}}
>
<Stack spacing={2} sx={{ flex: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, px: 1.5, pt: 1 }}>
<Typography
variant="h6"
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const drawerContent = (
<Stack sx={{ height: "100%", p: 2 }}>
<Box sx={{ px: 2, py: 3, mb: 1 }}>
<Typography variant="h6" color="primary.main" sx={{ letterSpacing: "-0.02em" }}>
Caddy Proxy
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ letterSpacing: "0.1em", textTransform: "uppercase" }}>
Manager
</Typography>
</Box>
<List sx={{ flex: 1, gap: 0.5, display: "flex", flexDirection: "column" }}>
{NAV_ITEMS.map((item) => {
const selected = pathname === item.href;
const Icon = item.icon;
return (
<ListItemButton
key={item.href}
component={Link}
href={item.href}
selected={selected}
onClick={() => isMobile && setMobileOpen(false)}
sx={{
fontWeight: 600,
fontSize: "1.125rem",
letterSpacing: "-0.02em",
color: "rgba(255, 255, 255, 0.95)"
}}
>
Caddy Proxy
</Typography>
<Typography
variant="caption"
sx={{
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
letterSpacing: "0.01em"
}}
>
Manager
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255, 255, 255, 0.06)" }} />
<Box
component="nav"
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
py: 0.5,
gap: 0.25
}}
>
{NAV_ITEMS.map((item) => {
const selected = pathname === item.href;
return (
<Box
key={item.href}
component={Link}
href={item.href}
sx={{
display: "flex",
alignItems: "center",
textDecoration: "none",
borderRadius: 1.25,
px: 2,
py: 1,
height: 36,
minHeight: 36,
maxHeight: 36,
backgroundColor: selected ? "rgba(255, 255, 255, 0.1)" : "transparent",
transition: "background-color 0.15s ease",
cursor: "pointer",
"&:hover": {
backgroundColor: selected ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.05)"
}
}}
>
<Typography
sx={{
fontWeight: 400,
fontSize: "0.875rem",
letterSpacing: "-0.005em",
color: selected ? "rgba(255, 255, 255, 0.95)" : "rgba(255, 255, 255, 0.65)",
lineHeight: "20px"
}}
>
{item.label}
</Typography>
</Box>
);
})}
</Box>
</Stack>
<Stack spacing={2}>
<Divider sx={{ borderColor: "rgba(255, 255, 255, 0.06)" }} />
<Box
component={Link}
href="/profile"
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
px: 1.5,
py: 1,
borderRadius: 1.25,
textDecoration: "none",
transition: "background-color 0.15s ease",
cursor: "pointer",
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.05)"
}
}}
>
<Avatar
src={user.image || undefined}
alt={user.name || user.email || "User"}
sx={{
bgcolor: "rgba(100, 100, 255, 0.2)",
border: "0.5px solid rgba(255, 255, 255, 0.15)",
color: "rgba(255, 255, 255, 0.95)",
fontSize: 13,
fontWeight: 500,
width: 32,
height: 32
}}
>
{(user.name ?? user.email ?? "A").slice(0, 2).toUpperCase()}
</Avatar>
<Box sx={{ overflow: "hidden" }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 500,
fontSize: "0.8125rem",
color: "rgba(255, 255, 255, 0.85)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
}}
>
{user.name ?? user.email ?? "Admin"}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: "0.6875rem",
color: "rgba(255, 255, 255, 0.4)"
}}
>
Administrator
</Typography>
</Box>
</Box>
<form action="/api/auth/logout" method="POST">
<Button
type="submit"
variant="text"
fullWidth
sx={{
color: "rgba(255, 255, 255, 0.6)",
py: 1,
fontSize: "0.8125rem",
fontWeight: 400,
textTransform: "none",
borderRadius: 1.25,
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.8)"
mb: 0.5,
color: selected ? "primary.contrastText" : "text.secondary",
"& .MuiListItemIcon-root": {
color: selected ? "inherit" : "text.secondary",
minWidth: 40
}
}}
>
Sign Out
</Button>
</form>
</Stack>
<ListItemIcon>
<Icon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
variant: "body2",
fontWeight: selected ? 600 : 500
}}
/>
</ListItemButton>
);
})}
</List>
<Box sx={{ mt: 2 }}>
<Divider sx={{ mb: 2, borderColor: "rgba(255,255,255,0.05)" }} />
<ListItemButton
onClick={() => {
if (isMobile) setMobileOpen(false);
router.push("/profile");
}}
sx={{
gap: 2,
px: 1,
mb: 2,
py: 1,
borderRadius: 1,
color: "text.primary"
}}
>
<Avatar
src={user.image || undefined}
alt={user.name || "User"}
sx={{ width: 40, height: 40, border: "2px solid", borderColor: "background.paper" }}
>
{(user.name?.[0] || "U").toUpperCase()}
</Avatar>
<Box sx={{ overflow: "hidden" }}>
<Typography variant="subtitle2" noWrap sx={{ color: "text.primary" }}>
{user.name || "Administrator"}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap display="block">
{user.email}
</Typography>
</Box>
</ListItemButton>
<form action="/api/auth/logout" method="POST">
<Button
type="submit"
fullWidth
variant="outlined"
color="inherit"
startIcon={<LogoutIcon />}
sx={{
justifyContent: "flex-start",
borderColor: "rgba(255,255,255,0.1)",
color: "text.secondary",
"&:hover": {
borderColor: "rgba(255,255,255,0.2)",
bgcolor: "rgba(255,255,255,0.02)",
color: "text.primary"
}
}}
>
Sign Out
</Button>
</form>
</Box>
</Stack>
);
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
{isMobile && (
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ position: "fixed", top: 16, left: 16, zIndex: 1200, bgcolor: "background.paper", boxShadow: 2 }}
>
<MenuIcon />
</IconButton>
)}
<Box
component="nav"
sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}
>
<Drawer
variant={isMobile ? "temporary" : "permanent"}
open={isMobile ? mobileOpen : true}
onClose={handleDrawerToggle}
ModalProps={{ keepMounted: true }}
sx={{
"& .MuiDrawer-paper": {
boxSizing: "border-box",
width: DRAWER_WIDTH,
borderRight: "1px solid",
borderColor: "divider",
bgcolor: "background.default"
}
}}
>
{drawerContent}
</Drawer>
</Box>
<Stack
<Box
component="main"
sx={{
flex: 1,
position: "relative",
marginLeft: "240px",
px: { xs: 3, md: 6, xl: 8 },
py: { xs: 5, md: 6 },
gap: 4,
bgcolor: "transparent",
overflowX: "hidden",
minHeight: "100vh"
flexGrow: 1,
p: { xs: 3, md: 5 },
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
mt: { xs: 6, md: 0 }
}}
>
<Box
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
background: OVERLAY_BACKGROUND
}}
/>
<Stack sx={{ position: "relative", gap: 4, width: "100%", maxWidth: 1160, mx: "auto" }}>{children}</Stack>
</Stack>
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
{children}
</Box>
</Box>
</Box>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,162 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Caddy Proxy Manager Modern Web UI for Caddy with automatic HTTPS management and reverse proxy configuration." />
<title>Caddy Proxy Manager - Modern Web UI for Caddy Server</title>
<meta name="description"
content="Caddy Proxy Manager - A modern, secure, and intuitive web interface for Caddy Server." />
<title>Caddy Proxy Manager</title>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://caddyproxymanager.com/" />
<meta property="og:title" content="Caddy Proxy Manager - Control Every Edge" />
<meta property="og:description" content="Caddy Proxy Manager Modern Web UI for Caddy with automatic HTTPS management and reverse proxy configuration." />
<meta property="og:description"
content="Caddy Proxy Manager Modern Web UI for Caddy with automatic HTTPS management and reverse proxy configuration." />
<meta property="og:image" content="https://caddyproxymanager.com/assets/images/preview.png" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<script defer src="scripts.js"></script>
</head>
<body>
<div class="aurora" aria-hidden="true"></div>
<header class="hero" id="top">
<nav class="nav">
<div class="logo">Caddy Proxy Manager</div>
<div class="nav-links">
<div class="aurora-bg"></div>
<header>
<div class="container header-inner">
<a href="#" class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
Caddy Proxy Manager
</a>
<nav class="nav-links">
<a href="#features">Features</a>
<a href="#showcase">Showcase</a>
<a href="#architecture">Architecture</a>
<a href="#deployment">Deploy</a>
<a href="https://github.com/fuomag9/caddy-proxy-manager" target="_blank" rel="noreferrer">GitHub</a>
</div>
</nav>
<div class="hero-content">
<p class="eyebrow">Web UI for Caddy Server</p>
<h1>Modern interface for Caddy's automatic HTTPS.</h1>
<p class="lede">
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.
</p>
<div class="cta-group">
<a class="cta primary" href="https://github.com/fuomag9/caddy-proxy-manager" target="_blank" rel="noreferrer">View on GitHub</a>
<a class="cta secondary" href="#deployment">Get Started</a>
</div>
<div class="metrics">
<div>
<span class="metric-value">Docker</span>
<span class="metric-label">Quick deployment</span>
</div>
<div>
<span class="metric-value">Full</span>
<span class="metric-label">Audit history</span>
</div>
<div>
<span class="metric-value">Auto</span>
<span class="metric-label">TLS with ACME</span>
</div>
</div>
<a href="#deployment">Data</a>
<a href="https://github.com/fuomag9/caddy-proxy-manager" target="_blank">GitHub</a>
</nav>
</div>
</header>
<main>
<section class="panel" id="features">
<div class="section-heading">
<p class="eyebrow">Key Features</p>
<h2>Streamlined Caddy management.</h2>
<p class="lede">Manage your Caddy server configuration through an intuitive web interface with built-in security and audit logging.</p>
<section class="hero container">
<h1>Control Every Edge.</h1>
<p>The modern, secure context for your reverse proxy. Manage Caddy with an intuitive interface, automatic HTTPS,
and detailed audit logging.</p>
<div class="btn-group">
<a href="#deployment" class="btn btn-primary">Get Started</a>
<a href="https://github.com/fuomag9/caddy-proxy-manager" target="_blank" class="btn btn-secondary">View
Source</a>
</div>
<div class="feature-grid">
<article class="card">
<h3>Reverse Proxy Management</h3>
<p>Configure reverse proxies, set custom headers, and manage domain routing through an easy-to-use web interface.</p>
</article>
<article class="card">
<h3>Certificate Management</h3>
<p>Automatic ACME certificate provisioning with Cloudflare DNS-01 support, plus manual certificate imports for custom setups.</p>
</article>
<article class="card">
<h3>Audit Logging</h3>
<p>Complete audit trail of all configuration changes with timestamps and user attribution for accountability.</p>
</article>
<article class="card">
<h3>Built-in Security</h3>
<p>Strong password requirements, secure session management, rate limiting, and HSTS headers protect your installation.</p>
</article>
<div class="showcase">
<div class="screenshot-main">
<img src="assets/screenshots/dashboard-main.png" alt="Caddy Proxy Manager Dashboard" loading="lazy" />
</div>
</div>
</section>
<section class="panel glass" id="showcase">
<div class="section-heading">
<p class="eyebrow">Screenshots</p>
<h2>See the interface.</h2>
<section id="features" class="container features">
<div class="section-header">
<h2>Powerful Simplicity</h2>
<p>Everything you need to manage your infrastructure, nothing you don't.</p>
</div>
<div class="showcase-grid">
<figure>
<img src="assets/screenshots/dashboard-main.png" alt="Primary dashboard overview" loading="lazy" />
<figcaption>Dashboard overview showing proxy hosts, certificates, and system status.</figcaption>
</figure>
<figure>
<img src="assets/screenshots/certificates.png" alt="Certificate lifecycle manager" loading="lazy" />
<figcaption>Certificate management with ACME automation and manual imports.</figcaption>
</figure>
<figure>
<img src="assets/screenshots/proxy-editor.png" alt="Proxy host editor" loading="lazy" />
<figcaption>Proxy host configuration with validation and testing.</figcaption>
</figure>
<div class="grid">
<div class="card">
<h3>Reverse Proxy</h3>
<p>Easily configure upstream pools, custom headers, and routing rules with a type-safe editor.</p>
</div>
<div class="card">
<h3>Auto HTTPS</h3>
<p>Zero-configuration TLS certificates via Let's Encrypt. Supports Cloudflare DNS-01 and HTTP-01 challenges
out of the
box.</p>
</div>
<div class="card">
<h3>Access Control</h3>
<p>Secure your endpoints with basic auth, IP access lists, or valid OAuth2/OIDC sessions.</p>
</div>
<div class="card">
<h3>Audit Log</h3>
<p>Every change is tracked. See who modified what and when, with full configuration diffs.</p>
</div>
<div class="card">
<h3>Modern Stack</h3>
<p>Built with Next.js 16, React Server Components, and Drizzle ORM for maximum performance.</p>
</div>
<div class="card">
<h3>Docker Ready</h3>
<p>Deploys in seconds with a single docker-compose file. Stateless application logic with persistent data.</p>
</div>
</div>
</section>
<section class="panel split" id="architecture">
<div>
<p class="eyebrow">Technology Stack</p>
<h2>Next.js, React, Drizzle ORM, and Caddy.</h2>
<p class="lede">Built with modern web technologies and integrates directly with Caddy's Admin API for real-time configuration management.</p>
<section id="architecture" class="container tech-stack">
<div class="section-header">
<h2>Designed for Reliability</h2>
</div>
<div class="tech-grid">
<div class="secondary-screenshots">
<img src="assets/screenshots/audit-log.png" alt="Audit Log" loading="lazy" />
<img src="assets/screenshots/proxy-editor.png" alt="Proxy Editor" loading="lazy" />
</div>
<div>
<ul class="tech-list">
<li>
<h4>Caddy Powered</h4>
<p>Uses Caddy's native Admin API for real-time configuration updates without restarts.</p>
</li>
<li>
<h4>Type-Safe & Secure</h4>
<p>End-to-end type safety with TypeScript. Secure session management and input validation.</p>
</li>
<li>
<h4>SQLite Database</h4>
<p>Self-contained data storage. Easy to backup, migrate, and maintain.</p>
</li>
<li>
<h4>React Interface</h4>
<p>A responsive, dark-mode first UI built with the latest React patterns for a snappy experience.</p>
</li>
</ul>
</div>
</div>
<ul class="pillars">
<li>
<span>Next.js App Router</span>
<p>Server-side rendering with React Server Components for fast page loads and smooth interactions.</p>
</li>
<li>
<span>SQLite Database</span>
<p>Drizzle ORM with SQLite for storing proxy configurations, certificates, and audit logs.</p>
</li>
<li>
<span>Caddy Admin API</span>
<p>Direct integration with Caddy's JSON API for applying configuration changes in real-time.</p>
</li>
</ul>
</section>
<section class="panel" id="deployment">
<div class="section-heading">
<p class="eyebrow">Quick Setup</p>
<h2>Deploy with Docker Compose.</h2>
<p class="lede">Get started quickly with Docker Compose. The setup includes persistent storage and environment-based configuration.</p>
</div>
<div class="code-block">
<pre><code> # Clone and enter
git clone https://github.com/fuomag9/caddy-proxy-manager.git
cd caddy-proxy-manager
<section id="deployment" class="deployment">
<div class="container section-header">
<h2>Deploy in Seconds</h2>
<p>Get up and running with Docker Compose.</p>
# 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
</code></pre>
<div class="code-block">
<span class="comment"># Clone the repository</span>
<span class="command">git clone https://github.com/fuomag9/caddy-proxy-manager.git</span>
<span class="command">cd caddy-proxy-manager</span>
<br>
<span class="comment"># Setup environment</span>
<span class="command">cp .env.example .env</span>
<br>
<span class="comment"># Start the stack</span>
<span class="command">docker compose up -d</span>
</div>
</div>
</section>
</main>
<footer>
<p>© <span id="year"></span> Caddy Proxy Manager. Crafted with intent.</p>
<div class="container">
<p>&copy; <span id="year"></span> Caddy Proxy Manager. Released under the MIT License.</p>
</div>
</footer>
</body>
</html>
</html>

View File

@@ -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;
}
}
}

View File

@@ -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 (
<Box>
<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}
/>
{helperText && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", ml: 4, mt: -0.5 }}>
{helperText}
</Typography>
)}
</Box>
);
}
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 (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "primary.main",
bgcolor: "rgba(99, 102, 241, 0.05)",
p: 2.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>
<Switch
name="authentik_enabled"
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
/>
</Stack>
<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
/>
<HiddenCheckboxField
name="authentik_set_host_header"
defaultChecked={setHostHeaderDefault}
label="Set Host Header for Outpost"
disabled={!enabled}
helperText="Recommended: Keep enabled. Only disable if using IP-based outpost access or troubleshooting routing issues."
/>
</Stack>
</Collapse>
</Stack>
</Box>
);
}

View File

@@ -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 (
<AppDialog
open={open}
onClose={onClose}
title="Create Proxy Host"
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}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
</Alert>
)}
<SettingsToggles />
<TextField name="name" label="Name" placeholder="My Service" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="app.example.com"
helperText="One per line or comma-separated"
multiline
minRows={2}
required
fullWidth
/>
<UpstreamInput />
<TextField select name="certificate_id" label="Certificate" defaultValue="" 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="" fullWidth>
<MenuItem value="">None</MenuItem>
{accessLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.name}
</MenuItem>
))}
</TextField>
<TextField
name="custom_pre_handlers_json"
label="Custom Pre-Handlers (JSON)"
placeholder='[{"handler": "headers", ...}]'
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": {...}}}'
helperText="Deep-merge into reverse_proxy handler"
multiline
minRows={3}
fullWidth
/>
<AuthentikFields defaults={authentikDefaults} />
</Stack>
</AppDialog>
);
}
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 (
<AppDialog
open={open}
onClose={onClose}
title="Edit Proxy Host"
maxWidth="md"
submitLabel="Save Changes"
onSubmit={() => {
(document.getElementById("edit-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<Stack component="form" id="edit-host-form" action={formAction} spacing={2.5}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
</Alert>
)}
<SettingsToggles
hstsSubdomains={host.hsts_subdomains}
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"
multiline
minRows={2}
fullWidth
/>
<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>
<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"
multiline
minRows={3}
fullWidth
/>
<AuthentikFields authentik={host.authentik} />
</Stack>
</AppDialog>
);
}
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 (
<AppDialog
open={open}
onClose={onClose}
title="Delete Proxy Host"
maxWidth="sm"
submitLabel="Delete"
onSubmit={() => {
(document.getElementById("delete-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<Stack component="form" id="delete-host-form" action={formAction} spacing={2}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
</Alert>
)}
<Typography variant="body1">
Are you sure you want to delete the proxy host <strong>{host.name}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary">
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}>
This action cannot be undone.
</Typography>
</Stack>
</AppDialog>
);
}

View File

@@ -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<HTMLInputElement>) => {
setValues(prev => ({ ...prev, [name]: event.target.checked }));
};
const handleEnabledChange = (_: React.ChangeEvent<HTMLInputElement>, 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 (
<Stack spacing={3}>
<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"}>
{values.enabled ? "Proxy Host Enabled" : "Proxy Host Paused"}
</Typography>
<Typography variant="body2" color="text.secondary">
{values.enabled
? "This host is active and routing traffic"
: "This host is disabled and will not respond to requests"}
</Typography>
</Box>
<Switch
checked={values.enabled}
onChange={handleEnabledChange}
color="primary"
/>
</Stack>
{/* 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" }} />}>
{settings.map((setting) => (
<Box 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>
<Switch
name={setting.name}
checked={values[setting.name as keyof typeof values] as boolean}
onChange={handleChange(setting.name as keyof typeof values)}
size="small"
color={setting.color as any}
/>
</Stack>
</Box>
))}
</Stack>
</Box>
</Stack>
);
}

View File

@@ -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<UpstreamEntry[]>(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 (
<Box>
<input type="hidden" name={name} value={serializedValue} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Upstreams
</Typography>
<Stack spacing={1.5}>
{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
value={entry.address}
onChange={(e) => handleAddressChange(index, e.target.value)}
placeholder="10.0.0.5:8080"
size="small"
fullWidth
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 }}
>
<RemoveCircleOutlineIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
))}
<Button
startIcon={<AddIcon />}
onClick={handleAdd}
size="small"
sx={{ alignSelf: "flex-start" }}
>
Add Upstream
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
Backend servers to proxy requests to
</Typography>
</Box>
);
}

View File

@@ -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 (
<Dialog
open={open}
onClose={onClose}
maxWidth={maxWidth}
fullWidth
PaperProps={{
elevation: 0,
variant: "outlined"
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6">{title}</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: "text.secondary" }}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{children}
</DialogContent>
<DialogActions sx={{ p: 2 }}>
{actions ? actions : (
<>
<Button onClick={onClose} color="inherit">
Cancel
</Button>
{onSubmit && (
<Button
onClick={onSubmit}
variant="contained"
disabled={isSubmitting}
>
{submitLabel}
</Button>
)}
</>
)}
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,76 @@
import {
Card,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Box
} from "@mui/material";
import { ReactNode } from "react";
type Column<T> = {
id: string;
label: string;
align?: "left" | "right" | "center";
width?: string | number;
render?: (row: T) => ReactNode;
};
type DataTableProps<T> = {
columns: Column<T>[];
data: T[];
keyField: keyof T;
emptyMessage?: string;
loading?: boolean;
};
export function DataTable<T>({
columns,
data,
keyField,
emptyMessage = "No data available",
loading = false
}: DataTableProps<T>) {
return (
<TableContainer component={Card} variant="outlined">
<Table>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell
key={col.id}
align={col.align || "left"}
width={col.width}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{data.length === 0 && !loading ? (
<TableRow>
<TableCell colSpan={columns.length} align="center" sx={{ py: 8 }}>
<Typography color="text.secondary">{emptyMessage}</Typography>
</TableCell>
</TableRow>
) : (
data.map((row) => (
<TableRow key={String(row[keyField])}>
{columns.map((col) => (
<TableCell key={col.id} align={col.align || "left"}>
{col.render ? col.render(row) : (row as any)[col.id]}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@@ -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 (
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2} sx={{ mb: 4 }}>
<Stack spacing={1}>
<Typography variant="h4" color="text.primary">
{title}
</Typography>
{description && (
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 600 }}>
{description}
</Typography>
)}
</Stack>
{action && (
<Button
variant="contained"
startIcon={action.icon ?? <AddIcon />}
onClick={action.onClick}
color="primary"
sx={{ fontWeight: 600 }}
>
{action.label}
</Button>
)}
</Stack>
);
}

View File

@@ -0,0 +1,37 @@
import { InputAdornment, TextField, TextFieldProps } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
export function SearchField(props: TextFieldProps) {
return (
<TextField
placeholder="Search..."
variant="outlined"
size="small"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: "text.secondary" }} />
</InputAdornment>
)
}
}}
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}
/>
);
}

View File

@@ -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<StatusType, { color: string; label: string }> = {
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 (
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 1,
px: 1.5,
py: 0.5,
borderRadius: "9999px",
bgcolor: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.08)",
...sx
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: config.color,
boxShadow: `0 0 8px ${config.color}66`
}}
/>
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "text.primary",
lineHeight: 1
}}
>
{displayLabel}
</Typography>
</Box>
);
}