Updated the UI
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ data
|
||||
*.log
|
||||
.env*
|
||||
/.idea
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -22,49 +22,59 @@ export default function DashboardLayoutClient({ user, children }: { user: UserRe
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
position: "relative",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="aside"
|
||||
sx={{
|
||||
width: 280,
|
||||
bgcolor: "background.paper",
|
||||
borderRight: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "rgba(10, 15, 25, 0.9)",
|
||||
backdropFilter: "blur(18px)",
|
||||
borderRight: "1px solid rgba(99, 102, 241, 0.2)",
|
||||
boxShadow: "24px 0 60px rgba(2, 6, 23, 0.45)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: 0.4 }}>
|
||||
Caddy Proxy Manager
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ textTransform: "uppercase", letterSpacing: 4, color: "rgba(148, 163, 184, 0.5)" }}
|
||||
>
|
||||
Caddy
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: -0.4,
|
||||
background: "linear-gradient(120deg, #7f5bff 0%, #22d3ee 70%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
color: "transparent"
|
||||
}}
|
||||
>
|
||||
Proxy Manager
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{user.name ?? user.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
|
||||
<Divider sx={{ borderColor: "rgba(148, 163, 184, 0.1)" }} />
|
||||
|
||||
<List component="nav" sx={{ flexGrow: 1, display: "grid", gap: 0.5 }}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const selected = pathname === item.href;
|
||||
return (
|
||||
<ListItemButton
|
||||
key={item.href}
|
||||
component={Link}
|
||||
href={item.href}
|
||||
selected={selected}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
"&.Mui-selected": {
|
||||
bgcolor: "primary.main",
|
||||
color: "common.black",
|
||||
"&:hover": { bgcolor: "primary.light" }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemButton key={item.href} component={Link} href={item.href} selected={selected} sx={{ borderRadius: 2 }}>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: selected ? 600 : 500 }} />
|
||||
</ListItemButton>
|
||||
);
|
||||
@@ -72,14 +82,44 @@ export default function DashboardLayoutClient({ user, children }: { user: UserRe
|
||||
</List>
|
||||
|
||||
<form action="/api/auth/logout" method="POST">
|
||||
<Button type="submit" variant="outlined" fullWidth>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.9), rgba(34, 211, 238, 0.9))",
|
||||
color: "#05030a",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.8), rgba(34, 211, 238, 0.8))",
|
||||
boxShadow: "0 18px 44px rgba(34, 211, 238, 0.35)"
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<Stack component="main" sx={{ flex: 1, p: { xs: 3, md: 5 }, gap: 4, bgcolor: "background.default" }}>
|
||||
{children}
|
||||
<Stack
|
||||
component="main"
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
p: { xs: 3, md: 6 },
|
||||
gap: 4,
|
||||
bgcolor: "transparent"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
background:
|
||||
"radial-gradient(circle at 20% -10%, rgba(56, 189, 248, 0.18), transparent 40%), radial-gradient(circle at 80% 0%, rgba(168, 85, 247, 0.15), transparent 45%)"
|
||||
}}
|
||||
/>
|
||||
<Stack sx={{ position: "relative", gap: 4 }}>{children}</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardActionArea, CardContent, Grid, Paper, Stack, Typography } from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { Card, CardActionArea, CardContent, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
type StatCard = {
|
||||
label: string;
|
||||
@@ -25,29 +26,58 @@ export default function OverviewClient({
|
||||
recentEvents: RecentEvent[];
|
||||
}) {
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" fontWeight={600}>
|
||||
<Stack spacing={5}>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="overline" sx={{ color: "rgba(148, 163, 184, 0.6)", letterSpacing: 4 }}>
|
||||
Control Center
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
background: "linear-gradient(120deg, rgba(127, 91, 255, 1) 0%, rgba(34, 211, 238, 0.9) 80%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
color: "transparent"
|
||||
}}
|
||||
>
|
||||
Welcome back, {userName}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Manage your Caddy reverse proxies, TLS certificates, and services with confidence.
|
||||
<Typography color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||
Everything you need to orchestrate Caddy proxies, certificates, and secure edge services lives here.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid container spacing={3}>
|
||||
{stats.map((stat) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={stat.label}>
|
||||
<Card elevation={3}>
|
||||
<CardActionArea component={Link} href={stat.href} sx={{ height: "100%" }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Grid key={stat.label} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: "100%",
|
||||
border: "1px solid rgba(148, 163, 184, 0.14)"
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
component={Link}
|
||||
href={stat.href}
|
||||
sx={{
|
||||
height: "100%",
|
||||
p: 0,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.16), rgba(34, 211, 238, 0.08))"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ letterSpacing: 1.2 }}>
|
||||
{stat.icon}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ mt: 1, mb: 0.5 }} fontWeight={600}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}>
|
||||
{stat.count}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">{stat.label}</Typography>
|
||||
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
{stat.label}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
@@ -56,11 +86,19 @@ export default function OverviewClient({
|
||||
</Grid>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: -0.2 }}>
|
||||
Recent Activity
|
||||
</Typography>
|
||||
{recentEvents.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: "center", color: "text.secondary", bgcolor: "background.paper" }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
color: "text.secondary",
|
||||
background: "rgba(12, 18, 30, 0.7)"
|
||||
}}
|
||||
>
|
||||
No activity recorded yet.
|
||||
</Paper>
|
||||
) : (
|
||||
@@ -69,7 +107,15 @@ export default function OverviewClient({
|
||||
<Paper
|
||||
key={`${event.created_at}-${index}`}
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, display: "flex", justifyContent: "space-between", bgcolor: "background.paper" }}
|
||||
sx={{
|
||||
p: 3,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
background: "linear-gradient(120deg, rgba(17, 25, 40, 0.9), rgba(15, 23, 42, 0.7))",
|
||||
border: "1px solid rgba(148, 163, 184, 0.08)"
|
||||
}}
|
||||
>
|
||||
<Typography fontWeight={500}>{event.summary}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
Checkbox
|
||||
} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Card, CardContent, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography, Checkbox } from "@mui/material";
|
||||
import type { AccessList } from "@/src/lib/models/access-lists";
|
||||
import type { Certificate } from "@/src/lib/models/certificates";
|
||||
import type { ProxyHost } from "@/src/lib/models/proxy-hosts";
|
||||
@@ -31,43 +16,89 @@ type Props = {
|
||||
|
||||
export default function ProxyHostsClient({ hosts, certificates, accessLists }: Props) {
|
||||
return (
|
||||
<Stack spacing={4} sx={{ width: "100%" }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" fontWeight={600}>
|
||||
<Stack spacing={5} sx={{ width: "100%" }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="overline" sx={{ color: "rgba(148, 163, 184, 0.6)", letterSpacing: 4 }}>
|
||||
HTTP Edge
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
background: "linear-gradient(120deg, rgba(127, 91, 255, 1) 0%, rgba(34, 211, 238, 0.9) 80%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
color: "transparent"
|
||||
}}
|
||||
>
|
||||
Proxy Hosts
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Define HTTP(S) reverse proxies managed by Caddy with built-in TLS orchestration.
|
||||
<Typography color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||
Define HTTP(S) reverse proxies orchestrated by Caddy with automated certificates, shields, and zero-downtime
|
||||
reloads.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
{hosts.map((host) => (
|
||||
<Grid item xs={12} md={6} key={host.id}>
|
||||
<Card sx={{ height: "100%" }}>
|
||||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
<Grid key={host.id} size={{ xs: 12, md: 6 }}>
|
||||
<Card
|
||||
sx={{
|
||||
height: "100%",
|
||||
border: "1px solid rgba(148, 163, 184, 0.12)",
|
||||
background: "linear-gradient(160deg, rgba(17, 25, 40, 0.95), rgba(12, 18, 30, 0.78))"
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ letterSpacing: -0.2 }}>
|
||||
{host.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{host.domains.join(", ")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={host.enabled ? "Enabled" : "Disabled"}
|
||||
color={host.enabled ? "success" : "warning"}
|
||||
variant={host.enabled ? "filled" : "outlined"}
|
||||
color={host.enabled ? "success" : "default"}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
borderRadius: 999,
|
||||
background: host.enabled
|
||||
? "linear-gradient(135deg, rgba(34, 197, 94, 0.22), rgba(52, 211, 153, 0.32))"
|
||||
: "rgba(148, 163, 184, 0.1)",
|
||||
border: "1px solid rgba(148, 163, 184, 0.2)",
|
||||
color: host.enabled ? "#4ade80" : "rgba(148,163,184,0.8)"
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Accordion elevation={0} disableGutters>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
|
||||
<Accordion
|
||||
elevation={0}
|
||||
disableGutters
|
||||
sx={{
|
||||
bgcolor: "transparent",
|
||||
borderRadius: 3,
|
||||
border: "1px solid rgba(148,163,184,0.12)",
|
||||
overflow: "hidden",
|
||||
"&::before": { display: "none" }
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: "rgba(226, 232, 240, 0.6)" }} />}
|
||||
sx={{ px: 2, bgcolor: "rgba(15, 23, 42, 0.45)" }}
|
||||
>
|
||||
<Typography fontWeight={600}>Edit configuration</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 0 }}>
|
||||
<Stack component="form" action={(formData) => updateProxyHostAction(host.id, formData)} spacing={2}>
|
||||
<AccordionDetails sx={{ px: 2, py: 3 }}>
|
||||
<Stack component="form" action={(formData) => updateProxyHostAction(host.id, formData)} spacing={2.5}>
|
||||
<TextField name="name" label="Name" defaultValue={host.name} required fullWidth />
|
||||
<TextField
|
||||
name="domains"
|
||||
@@ -104,23 +135,23 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists }: P
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 1 }}>
|
||||
<HiddenCheckboxField name="ssl_forced" defaultChecked={host.ssl_forced} label="Force HTTPS" />
|
||||
<HiddenCheckboxField name="hsts_enabled" defaultChecked={host.hsts_enabled} label="HSTS" />
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 1.5,
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
<HiddenCheckboxField
|
||||
name="hsts_subdomains"
|
||||
defaultChecked={host.hsts_subdomains}
|
||||
label="Include subdomains in HSTS"
|
||||
/>
|
||||
<HiddenCheckboxField
|
||||
name="allow_websocket"
|
||||
defaultChecked={host.allow_websocket}
|
||||
label="Allow WebSocket"
|
||||
/>
|
||||
<HiddenCheckboxField
|
||||
name="preserve_host_header"
|
||||
defaultChecked={host.preserve_host_header}
|
||||
label="Preserve host header"
|
||||
name="skip_https_hostname_validation"
|
||||
defaultChecked={host.skip_https_hostname_validation}
|
||||
label="Skip HTTPS hostname validation"
|
||||
/>
|
||||
<HiddenCheckboxField name="enabled" defaultChecked={host.enabled} label="Enabled" />
|
||||
</Box>
|
||||
@@ -146,12 +177,22 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists }: P
|
||||
</Grid>
|
||||
|
||||
<Stack spacing={2} component="section">
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
Create proxy host
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack component="form" action={createProxyHostAction} spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
Create proxy host
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Deploy a new reverse proxy route powered by Caddy.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Card
|
||||
sx={{
|
||||
border: "1px solid rgba(148, 163, 184, 0.12)",
|
||||
background: "linear-gradient(160deg, rgba(19, 28, 45, 0.95), rgba(12, 18, 30, 0.78))"
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 3, md: 4 } }}>
|
||||
<Stack component="form" action={createProxyHostAction} spacing={2.5}>
|
||||
<TextField name="name" label="Name" placeholder="Internal service" required fullWidth />
|
||||
<TextField
|
||||
name="domains"
|
||||
@@ -187,12 +228,60 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists }: P
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 1 }}>
|
||||
<FormControlLabel control={<Checkbox name="ssl_forced" defaultChecked />} label="Force HTTPS" />
|
||||
<FormControlLabel control={<Checkbox name="hsts_enabled" defaultChecked />} label="HSTS" />
|
||||
<FormControlLabel control={<Checkbox name="allow_websocket" defaultChecked />} label="Allow WebSocket" />
|
||||
<FormControlLabel control={<Checkbox name="preserve_host_header" defaultChecked />} label="Preserve host header" />
|
||||
<FormControlLabel control={<Checkbox name="enabled" defaultChecked />} label="Enabled" />
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 1.5,
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="hsts_subdomains"
|
||||
sx={{
|
||||
color: "rgba(148, 163, 184, 0.6)",
|
||||
"&.Mui-checked": { color: "#7f5bff" }
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Include subdomains in HSTS"
|
||||
sx={{ width: "100%", m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="skip_https_hostname_validation"
|
||||
sx={{
|
||||
color: "rgba(148, 163, 184, 0.6)",
|
||||
"&.Mui-checked": { color: "#7f5bff" }
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Skip HTTPS hostname validation"
|
||||
sx={{ width: "100%", m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="enabled"
|
||||
defaultChecked
|
||||
sx={{
|
||||
color: "rgba(148, 163, 184, 0.6)",
|
||||
"&.Mui-checked": { color: "#7f5bff" }
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
sx={{ width: "100%", m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button type="submit" variant="contained">
|
||||
@@ -209,9 +298,24 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists }: P
|
||||
|
||||
function HiddenCheckboxField({ name, defaultChecked, label }: { name: string; defaultChecked: boolean; label: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
|
||||
<input type="hidden" name={`${name}_present`} value="1" />
|
||||
<FormControlLabel control={<Checkbox name={name} defaultChecked={defaultChecked} />} label={label} />
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name={name}
|
||||
defaultChecked={defaultChecked}
|
||||
sx={{
|
||||
color: "rgba(148, 163, 184, 0.6)",
|
||||
"&.Mui-checked": {
|
||||
color: "#7f5bff"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
sx={{ width: "100%", m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,11 +28,8 @@ export async function createProxyHostAction(formData: FormData) {
|
||||
upstreams: parseCsv(formData.get("upstreams")),
|
||||
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : null,
|
||||
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : null,
|
||||
ssl_forced: parseCheckbox(formData.get("ssl_forced")),
|
||||
hsts_enabled: parseCheckbox(formData.get("hsts_enabled")),
|
||||
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")),
|
||||
allow_websocket: parseCheckbox(formData.get("allow_websocket")),
|
||||
preserve_host_header: parseCheckbox(formData.get("preserve_host_header")),
|
||||
skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")),
|
||||
enabled: parseCheckbox(formData.get("enabled"))
|
||||
},
|
||||
user.id
|
||||
@@ -51,11 +48,8 @@ export async function updateProxyHostAction(id: number, formData: FormData) {
|
||||
upstreams: formData.get("upstreams") ? parseCsv(formData.get("upstreams")) : undefined,
|
||||
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : undefined,
|
||||
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : undefined,
|
||||
ssl_forced: boolField("ssl_forced"),
|
||||
hsts_enabled: boolField("hsts_enabled"),
|
||||
hsts_subdomains: boolField("hsts_subdomains"),
|
||||
allow_websocket: boolField("allow_websocket"),
|
||||
preserve_host_header: boolField("preserve_host_header"),
|
||||
skip_https_hostname_validation: boolField("skip_https_hostname_validation"),
|
||||
enabled: boolField("enabled")
|
||||
},
|
||||
user.id
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
background: #0a0d13;
|
||||
color: #f9fafc;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
background: transparent;
|
||||
color: #f5f7fb;
|
||||
min-height: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -11,29 +11,111 @@ export default function Providers({ children }: { children: ReactNode }) {
|
||||
palette: {
|
||||
mode: "dark",
|
||||
background: {
|
||||
default: "#05070c",
|
||||
paper: "#0c111d"
|
||||
default: "#040507",
|
||||
paper: "rgba(15, 21, 33, 0.88)"
|
||||
},
|
||||
primary: {
|
||||
main: "#00bcd4"
|
||||
main: "#7f5bff"
|
||||
},
|
||||
secondary: {
|
||||
main: "#22d3ee"
|
||||
},
|
||||
text: {
|
||||
primary: "#f5f7fb",
|
||||
secondary: "rgba(232, 236, 255, 0.6)"
|
||||
}
|
||||
},
|
||||
typography: {
|
||||
fontFamily: ['"Inter"', '"Segoe UI"', "Roboto", "sans-serif"].join(","),
|
||||
h4: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.015em"
|
||||
}
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
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%)"
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: "none",
|
||||
borderRadius: 999
|
||||
borderRadius: 999,
|
||||
fontWeight: 600,
|
||||
paddingInline: 20
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: "none"
|
||||
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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: "rgba(15, 21, 33, 0.92)",
|
||||
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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "rgba(13, 18, 30, 0.75)",
|
||||
borderRadius: 14,
|
||||
"& fieldset": {
|
||||
borderColor: "rgba(148, 163, 184, 0.2)"
|
||||
},
|
||||
"&:hover fieldset": {
|
||||
borderColor: "rgba(127, 91, 255, 0.6)"
|
||||
},
|
||||
"&.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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Card, CardContent, FormControl, FormControlLabel, FormLabel, Grid, Radio, RadioGroup, Stack, TextField, Typography } from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { Box, Button, Card, CardContent, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Stack, TextField, Typography } from "@mui/material";
|
||||
|
||||
export default function OAuthSetupClient({ startSetup }: { startSetup: (formData: FormData) => void }) {
|
||||
const [providerType, setProviderType] = useState<"authentik" | "generic">("authentik");
|
||||
@@ -70,13 +71,13 @@ export default function OAuthSetupClient({ startSetup }: { startSetup: (formData
|
||||
<TextField name="clientSecret" label="Client secret" placeholder="client-secret" required fullWidth type="password" />
|
||||
<TextField name="scopes" label="Scopes" defaultValue="openid email profile" fullWidth />
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField name="emailClaim" label="Email claim" defaultValue="email" fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField name="nameClaim" label="Name claim" defaultValue="name" fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField name="avatarClaim" label="Avatar claim" defaultValue="picture" fullWidth />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"next": "^16.0.1",
|
||||
@@ -1694,6 +1695,15 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1719,7 +1729,6 @@
|
||||
"version": "24.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
|
||||
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -6803,7 +6812,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"next": "^16.0.1",
|
||||
|
||||
@@ -69,6 +69,8 @@ function createOAuthProvider() {
|
||||
}
|
||||
|
||||
// Generic OAuth2 provider for non-OIDC providers
|
||||
const checks: Array<"pkce" | "state" | "none"> = ["state", "pkce"];
|
||||
|
||||
return {
|
||||
id: "oauth",
|
||||
name: "OAuth2",
|
||||
@@ -87,7 +89,7 @@ function createOAuthProvider() {
|
||||
},
|
||||
clientId: settings.clientId,
|
||||
clientSecret: settings.clientSecret,
|
||||
checks: ["state", "pkce"] as const,
|
||||
checks,
|
||||
profile(profile: any) {
|
||||
const emailClaim = settings.emailClaim || "email";
|
||||
const nameClaim = settings.nameClaim || "name";
|
||||
@@ -103,9 +105,11 @@ function createOAuthProvider() {
|
||||
};
|
||||
}
|
||||
|
||||
const oauthProvider = createOAuthProvider();
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: CustomAdapter(),
|
||||
providers: [createOAuthProvider()].filter(Boolean),
|
||||
providers: oauthProvider ? [oauthProvider] : [],
|
||||
session: {
|
||||
strategy: "database",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
|
||||
@@ -20,6 +20,7 @@ type ProxyHostRow = {
|
||||
hsts_subdomains: number;
|
||||
allow_websocket: number;
|
||||
preserve_host_header: number;
|
||||
skip_https_hostname_validation: number;
|
||||
meta: string | null;
|
||||
enabled: number;
|
||||
};
|
||||
@@ -149,7 +150,18 @@ function buildProxyRoutes(
|
||||
handlers.push({
|
||||
handler: "reverse_proxy",
|
||||
upstreams: upstreams.map((dial) => ({ dial })),
|
||||
preserve_host: Boolean(row.preserve_host_header)
|
||||
preserve_host: Boolean(row.preserve_host_header),
|
||||
...(row.skip_https_hostname_validation
|
||||
? {
|
||||
transport: {
|
||||
http: {
|
||||
tls: {
|
||||
insecure_skip_verify: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
const route: CaddyHttpRoute = {
|
||||
@@ -311,7 +323,7 @@ function buildCaddyDocument() {
|
||||
const proxyHosts = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, meta, enabled
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation, meta, enabled
|
||||
FROM proxy_hosts`
|
||||
)
|
||||
.all() as ProxyHostRow[];
|
||||
|
||||
@@ -200,6 +200,17 @@ const MIGRATIONS: Migration[] = [
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
description: "add skip https hostname validation flag",
|
||||
up: (db) => {
|
||||
const columns = db.prepare("PRAGMA table_info(proxy_hosts)").all() as { name: string }[];
|
||||
const hasColumn = columns.some((column) => column.name === "skip_https_hostname_validation");
|
||||
if (!hasColumn) {
|
||||
db.exec("ALTER TABLE proxy_hosts ADD COLUMN skip_https_hostname_validation INTEGER NOT NULL DEFAULT 0;");
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -208,7 +219,8 @@ export function runMigrations(db: Database.Database) {
|
||||
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY);");
|
||||
|
||||
const appliedStmt = db.prepare("SELECT id FROM schema_migrations");
|
||||
const applied = new Set<number>(appliedStmt.all().map((row: { id: number }) => row.id));
|
||||
const appliedRows = appliedStmt.all() as Array<{ id: number }>;
|
||||
const applied = new Set<number>(appliedRows.map((row) => row.id));
|
||||
|
||||
const insertStmt = db.prepare("INSERT INTO schema_migrations (id) VALUES (?)");
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ProxyHost = {
|
||||
hsts_subdomains: boolean;
|
||||
allow_websocket: boolean;
|
||||
preserve_host_header: boolean;
|
||||
skip_https_hostname_validation: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -30,6 +31,7 @@ export type ProxyHostInput = {
|
||||
hsts_subdomains?: boolean;
|
||||
allow_websocket?: boolean;
|
||||
preserve_host_header?: boolean;
|
||||
skip_https_hostname_validation?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -46,6 +48,7 @@ function parseProxyHost(row: any): ProxyHost {
|
||||
hsts_subdomains: Boolean(row.hsts_subdomains),
|
||||
allow_websocket: Boolean(row.allow_websocket),
|
||||
preserve_host_header: Boolean(row.preserve_host_header),
|
||||
skip_https_hostname_validation: Boolean(row.skip_https_hostname_validation),
|
||||
enabled: Boolean(row.enabled),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
@@ -56,7 +59,8 @@ export function listProxyHosts(): ProxyHost[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, enabled, created_at, updated_at
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation,
|
||||
enabled, created_at, updated_at
|
||||
FROM proxy_hosts
|
||||
ORDER BY created_at DESC`
|
||||
)
|
||||
@@ -78,8 +82,9 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
|
||||
.prepare(
|
||||
`INSERT INTO proxy_hosts
|
||||
(name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, enabled, created_at, updated_at, owner_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation,
|
||||
enabled, created_at, updated_at, owner_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.name.trim(),
|
||||
@@ -87,11 +92,12 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
|
||||
JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
input.certificate_id ?? null,
|
||||
input.access_list_id ?? null,
|
||||
input.ssl_forced ? 1 : 0,
|
||||
(input.ssl_forced ?? true) ? 1 : 0,
|
||||
(input.hsts_enabled ?? true) ? 1 : 0,
|
||||
input.hsts_subdomains ? 1 : 0,
|
||||
(input.allow_websocket ?? true) ? 1 : 0,
|
||||
(input.preserve_host_header ?? true) ? 1 : 0,
|
||||
input.skip_https_hostname_validation ? 1 : 0,
|
||||
(input.enabled ?? true) ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
@@ -120,7 +126,8 @@ export function getProxyHost(id: number): ProxyHost | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, enabled, created_at, updated_at
|
||||
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation,
|
||||
enabled, created_at, updated_at
|
||||
FROM proxy_hosts WHERE id = ?`
|
||||
)
|
||||
.get(id);
|
||||
@@ -143,7 +150,8 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
db.prepare(
|
||||
`UPDATE proxy_hosts
|
||||
SET name = ?, domains = ?, upstreams = ?, certificate_id = ?, access_list_id = ?, ssl_forced = ?, hsts_enabled = ?,
|
||||
hsts_subdomains = ?, allow_websocket = ?, preserve_host_header = ?, enabled = ?, updated_at = ?
|
||||
hsts_subdomains = ?, allow_websocket = ?, preserve_host_header = ?, skip_https_hostname_validation = ?,
|
||||
enabled = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.name ?? existing.name,
|
||||
@@ -156,6 +164,7 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
(input.hsts_subdomains ?? existing.hsts_subdomains) ? 1 : 0,
|
||||
(input.allow_websocket ?? existing.allow_websocket) ? 1 : 0,
|
||||
(input.preserve_host_header ?? existing.preserve_host_header) ? 1 : 0,
|
||||
(input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation) ? 1 : 0,
|
||||
(input.enabled ?? existing.enabled) ? 1 : 0,
|
||||
now,
|
||||
id
|
||||
|
||||
Reference in New Issue
Block a user