Updated the UI

This commit is contained in:
fuomag9
2025-10-31 23:25:04 +01:00
parent d9ced96e1b
commit b064003c34
14 changed files with 455 additions and 138 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ data
*.log
.env*
/.idea
tsconfig.tsbuildinfo

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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
View File

@@ -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": {

View File

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

View File

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

View File

@@ -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[];

View File

@@ -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 (?)");

View File

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