finalized UI and website for 1.0 release
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
site/assets/screenshots/audit-log.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 696 KiB After Width: | Height: | Size: 98 KiB |
243
site/index.html
@@ -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>© <span id="year"></span> Caddy Proxy Manager. Released under the MIT License.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
441
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/components/proxy-hosts/AuthentikFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
src/components/proxy-hosts/HostDialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/proxy-hosts/SettingsToggles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/components/proxy-hosts/UpstreamInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/ui/AppDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/ui/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/SearchField.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/components/ui/StatusChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||