Swapped the entire UI to Material UI, applied a global dark theme, and removed all of the old styled-jsx/CSS-module styling

This commit is contained in:
fuomag9
2025-10-31 21:03:02 +01:00
parent 315192fb54
commit 29acf06f75
41 changed files with 3122 additions and 2445 deletions

View File

@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { Alert, Box, Button, Card, CardContent, Stack, Typography } from "@mui/material";
export default function LoginClient({ oauthConfigured, startOAuth }: { oauthConfigured: boolean; startOAuth: (formData: FormData) => void }) {
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", bgcolor: "background.default" }}>
<Card sx={{ maxWidth: 420, width: "100%", p: 1.5 }} elevation={6}>
<CardContent>
<Stack spacing={3} textAlign="center">
<Stack spacing={1}>
<Typography variant="h5" fontWeight={600}>
Caddy Proxy Manager
</Typography>
<Typography color="text.secondary">
Sign in with your organization&apos;s OAuth2 provider to continue.
</Typography>
</Stack>
{oauthConfigured ? (
<Box component="form" action={startOAuth}>
<Button type="submit" variant="contained" size="large" fullWidth>
Sign in with OAuth2
</Button>
</Box>
) : (
<Alert severity="warning">
The system administrator needs to configure OAuth2 settings before logins are allowed. If this is a fresh installation,
start with the <Link href="/setup/oauth">OAuth setup wizard</Link>.
</Alert>
)}
</Stack>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -2,9 +2,10 @@ import { redirect } from "next/navigation";
import { getSession } from "@/src/lib/auth/session";
import { buildAuthorizationUrl } from "@/src/lib/auth/oauth";
import { getOAuthSettings } from "@/src/lib/settings";
import LoginClient from "./LoginClient";
export default async function LoginPage() {
const session = getSession();
const session = await getSession();
if (session) {
redirect("/");
}
@@ -17,69 +18,5 @@ export default async function LoginPage() {
redirect(target);
}
return (
<div className="auth-wrapper">
<h1>Caddy Proxy Manager</h1>
<p>Sign in with your organization&apos;s OAuth2 provider to continue.</p>
{oauthConfigured ? (
<form action={startOAuth}>
<button type="submit" className="primary">
Sign in with OAuth2
</button>
</form>
) : (
<div className="notice">
<p>
The system administrator needs to configure OAuth2 settings before logins are allowed. If this is a fresh installation, start
with the{" "}
<a href="/setup/oauth">
OAuth setup wizard
</a>
.
</p>
</div>
)}
<style jsx>{`
.auth-wrapper {
max-width: 420px;
margin: 20vh auto;
padding: 3rem;
background: rgba(10, 17, 28, 0.92);
border-radius: 16px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
text-align: center;
}
h1 {
margin: 0 0 1rem;
}
p {
margin: 0 0 1.5rem;
color: rgba(255, 255, 255, 0.75);
}
.primary {
padding: 0.75rem 1.5rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.primary:hover {
opacity: 0.9;
}
.notice {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 12px;
background: rgba(255, 193, 7, 0.12);
color: #ffc107;
font-weight: 500;
}
.notice a {
color: #fff;
}
`}</style>
</div>
);
return <LoginClient oauthConfigured={oauthConfigured} startOAuth={startOAuth} />;
}

View File

@@ -0,0 +1,86 @@
"use client";
import { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Box, Button, Divider, List, ListItemButton, ListItemText, Stack, Typography } from "@mui/material";
import type { UserRecord } from "@/src/lib/auth/session";
const NAV_ITEMS = [
{ href: "/", label: "Overview" },
{ href: "/proxy-hosts", label: "Proxy Hosts" },
{ href: "/redirects", label: "Redirects" },
{ href: "/dead-hosts", label: "Dead Hosts" },
{ href: "/streams", label: "Streams" },
{ href: "/access-lists", label: "Access Lists" },
{ href: "/certificates", label: "Certificates" },
{ href: "/settings", label: "Settings" },
{ href: "/audit-log", label: "Audit Log" }
] as const;
export default function DashboardLayoutClient({ user, children }: { user: UserRecord; children: ReactNode }) {
const pathname = usePathname();
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<Box
component="aside"
sx={{
width: 280,
bgcolor: "background.paper",
borderRight: 1,
borderColor: "divider",
display: "flex",
flexDirection: "column",
gap: 3,
p: 3
}}
>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: 0.4 }}>
Caddy Proxy Manager
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{user.name ?? user.email}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,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" }
}
}}
>
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: selected ? 600 : 500 }} />
</ListItemButton>
);
})}
</List>
<form action="/api/auth/logout" method="POST">
<Button type="submit" variant="outlined" fullWidth>
Sign out
</Button>
</form>
</Box>
<Stack component="main" sx={{ flex: 1, p: { xs: 3, md: 5 }, gap: 4, bgcolor: "background.default" }}>
{children}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { Card, CardActionArea, CardContent, Grid, Paper, Stack, Typography } from "@mui/material";
type StatCard = {
label: string;
icon: string;
count: number;
href: string;
};
type RecentEvent = {
summary: string;
created_at: string;
};
export default function OverviewClient({
userName,
stats,
recentEvents
}: {
userName: string;
stats: StatCard[];
recentEvents: RecentEvent[];
}) {
return (
<Stack spacing={4}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Welcome back, {userName}
</Typography>
<Typography color="text.secondary">
Manage your Caddy reverse proxies, TLS certificates, and services with confidence.
</Typography>
</Stack>
<Grid container spacing={2}>
{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">
{stat.icon}
</Typography>
<Typography variant="h4" sx={{ mt: 1, mb: 0.5 }} fontWeight={600}>
{stat.count}
</Typography>
<Typography color="text.secondary">{stat.label}</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
<Stack spacing={2}>
<Typography variant="h6" fontWeight={600}>
Recent Activity
</Typography>
{recentEvents.length === 0 ? (
<Paper elevation={0} sx={{ p: 3, textAlign: "center", color: "text.secondary", bgcolor: "background.paper" }}>
No activity recorded yet.
</Paper>
) : (
<Stack spacing={1.5}>
{recentEvents.map((event, index) => (
<Paper
key={`${event.created_at}-${index}`}
elevation={0}
sx={{ p: 2.5, display: "flex", justifyContent: "space-between", bgcolor: "background.paper" }}
>
<Typography fontWeight={500}>{event.summary}</Typography>
<Typography variant="body2" color="text.secondary">
{new Date(event.created_at).toLocaleString()}
</Typography>
</Paper>
))}
</Stack>
)}
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import {
Box,
Button,
Card,
CardContent,
Divider,
IconButton,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
Stack,
TextField,
Typography
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import type { AccessList } from "@/src/lib/models/access-lists";
import {
addAccessEntryAction,
createAccessListAction,
deleteAccessEntryAction,
deleteAccessListAction,
updateAccessListAction
} from "./actions";
type Props = {
lists: AccessList[];
};
export default function AccessListsClient({ lists }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Access Lists
</Typography>
<Typography color="text.secondary">Protect proxy hosts with HTTP basic authentication credentials.</Typography>
</Stack>
<Stack spacing={3}>
{lists.map((list) => (
<Card key={list.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Stack component="form" action={(formData) => updateAccessListAction(list.id, formData)} spacing={2}>
<Typography variant="h6" fontWeight={600}>
Access List
</Typography>
<TextField name="name" label="Name" defaultValue={list.name} fullWidth />
<TextField
name="description"
label="Description"
defaultValue={list.description ?? ""}
multiline
minRows={2}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button type="submit" variant="contained">
Save
</Button>
<Button
type="submit"
formAction={deleteAccessListAction.bind(null, list.id)}
variant="outlined"
color="error"
>
Delete list
</Button>
</Box>
</Stack>
<Divider sx={{ my: 1 }} />
<Stack spacing={1.5}>
<Typography fontWeight={600}>Accounts</Typography>
{list.entries.length === 0 ? (
<Typography color="text.secondary">No credentials configured.</Typography>
) : (
<List dense disablePadding>
{list.entries.map((entry) => (
<ListItem key={entry.id} sx={{ bgcolor: "background.default", borderRadius: 2, mb: 1 }}>
<ListItemText primary={entry.username} secondary={`Created ${new Date(entry.created_at).toLocaleDateString()}`} />
<ListItemSecondaryAction>
<form action={deleteAccessEntryAction.bind(null, list.id, entry.id)}>
<IconButton type="submit" edge="end" color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</form>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</Stack>
<Divider sx={{ my: 1 }} />
<Stack component="form" action={(formData) => addAccessEntryAction(list.id, formData)} spacing={1.5} direction={{ xs: "column", sm: "row" }}>
<TextField name="username" label="Username" required fullWidth />
<TextField name="password" label="Password" type="password" required fullWidth />
<Button type="submit" variant="contained">
Add
</Button>
</Stack>
</CardContent>
</Card>
))}
</Stack>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create access list
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createAccessListAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Internal users" required fullWidth />
<TextField name="description" label="Description" placeholder="Optional description" multiline minRows={2} fullWidth />
<TextField
name="users"
label="Seed members"
helperText="One per line, username:password"
multiline
minRows={3}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Access List
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -11,7 +11,7 @@ import {
} from "@/src/lib/models/access-lists";
export async function createAccessListAction(formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
const rawUsers = String(formData.get("users") ?? "");
const accounts = rawUsers
.split("\n")
@@ -35,7 +35,7 @@ export async function createAccessListAction(formData: FormData) {
}
export async function updateAccessListAction(id: number, formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await updateAccessList(
id,
{
@@ -48,13 +48,13 @@ export async function updateAccessListAction(id: number, formData: FormData) {
}
export async function deleteAccessListAction(id: number) {
const { user } = requireUser();
const { user } = await requireUser();
await deleteAccessList(id, user.id);
revalidatePath("/access-lists");
}
export async function addAccessEntryAction(id: number, formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await addAccessListEntry(
id,
{
@@ -67,7 +67,7 @@ export async function addAccessEntryAction(id: number, formData: FormData) {
}
export async function deleteAccessEntryAction(accessListId: number, entryId: number) {
const { user } = requireUser();
const { user } = await requireUser();
await removeAccessListEntry(accessListId, entryId, user.id);
revalidatePath("/access-lists");
}

View File

@@ -1,207 +1,7 @@
import AccessListsClient from "./AccessListsClient";
import { listAccessLists } from "@/src/lib/models/access-lists";
import { addAccessEntryAction, createAccessListAction, deleteAccessEntryAction, deleteAccessListAction, updateAccessListAction } from "./actions";
export default function AccessListsPage() {
const lists = listAccessLists();
return (
<div className="page">
<header>
<h1>Access Lists</h1>
<p>Protect proxy hosts with HTTP basic authentication credentials.</p>
</header>
<section className="grid">
{lists.map((list) => (
<div className="card" key={list.id}>
<form action={(formData) => updateAccessListAction(list.id, formData)} className="header">
<div>
<input name="name" defaultValue={list.name} />
<textarea name="description" defaultValue={list.description ?? ""} rows={2} placeholder="Description" />
</div>
<button type="submit" className="primary small">
Save
</button>
</form>
<div className="entries">
<h3>Accounts</h3>
{list.entries.length === 0 ? (
<p className="empty">No credentials configured.</p>
) : (
<ul>
{list.entries.map((entry) => (
<li key={entry.id}>
<span>{entry.username}</span>
<form action={() => deleteAccessEntryAction(list.id, entry.id)}>
<button type="submit" className="ghost">
Remove
</button>
</form>
</li>
))}
</ul>
)}
</div>
<form action={(formData) => addAccessEntryAction(list.id, formData)} className="add-entry">
<input name="username" placeholder="Username" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit" className="primary small">
Add
</button>
</form>
<form action={() => deleteAccessListAction(list.id)}>
<button type="submit" className="danger">
Delete list
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create access list</h2>
<form action={createAccessListAction} className="form">
<label>
Name
<input name="name" placeholder="Internal users" required />
</label>
<label>
Description
<textarea name="description" placeholder="Optional description" rows={2} />
</label>
<label>
Seed members (one per line, username:password)
<textarea name="users" placeholder="alice:password123" rows={4} />
</label>
<div className="actions">
<button type="submit" className="primary">
Create Access List
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.header input,
.header textarea {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.entries ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.entries li {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(8, 12, 20, 0.9);
border-radius: 10px;
padding: 0.6rem 0.8rem;
}
.entries span {
font-weight: 500;
}
.empty {
color: rgba(255, 255, 255, 0.5);
}
.add-entry {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.6rem;
}
.add-entry input {
padding: 0.6rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.primary {
padding: 0.6rem 1.3rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.primary.small {
padding: 0.45rem 1rem;
}
.ghost {
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
align-self: flex-start;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.form input,
.form textarea {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.actions {
display: flex;
justify-content: flex-end;
}
`}</style>
</div>
);
return <AccessListsClient lists={lists} />;
}

View File

@@ -0,0 +1,41 @@
"use client";
import { Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material";
type EventRow = {
id: number;
created_at: string;
user: string;
summary: string;
};
export default function AuditLogClient({ events }: { events: EventRow[] }) {
return (
<Stack spacing={2} sx={{ width: "100%" }}>
<Typography variant="h4" fontWeight={600}>
Audit Log
</Typography>
<Typography color="text.secondary">Review configuration changes and user activity.</Typography>
<TableContainer component={Paper} sx={{ bgcolor: "background.paper" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>User</TableCell>
<TableCell>Summary</TableCell>
</TableRow>
</TableHead>
<TableBody>
{events.map((event) => (
<TableRow key={event.id} hover>
<TableCell>{new Date(event.created_at).toLocaleString()}</TableCell>
<TableCell>{event.user}</TableCell>
<TableCell>{event.summary}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Stack>
);
}

View File

@@ -1,72 +1,20 @@
import AuditLogClient from "./AuditLogClient";
import { listAuditEvents } from "@/src/lib/models/audit";
import { listUsers } from "@/src/lib/models/user";
export default function AuditLogPage() {
const events = listAuditEvents(200);
const users = new Map(listUsers().map((user) => [user.id, user]));
const users = listUsers();
const userMap = new Map(users.map((user) => [user.id, user]));
return (
<div className="page">
<header>
<h1>Audit Log</h1>
<p>Review configuration changes and user activity.</p>
</header>
<table>
<thead>
<tr>
<th>When</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{events.map((event) => {
const user = event.user_id ? users.get(event.user_id) : null;
return (
<tr key={event.id}>
<td>{new Date(event.created_at).toLocaleString()}</td>
<td>{user ? user.name ?? user.email : "System"}</td>
<td>{event.action}</td>
<td>{event.entity_type}</td>
<td>{event.summary ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
table {
width: 100%;
border-collapse: collapse;
border-radius: 16px;
overflow: hidden;
}
thead {
background: rgba(16, 24, 38, 0.95);
}
th,
td {
padding: 0.9rem 1.1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
tbody tr:nth-child(even) {
background: rgba(8, 12, 20, 0.9);
}
tbody tr:hover {
background: rgba(0, 114, 255, 0.1);
}
`}</style>
</div>
<AuditLogClient
events={events.map((event) => ({
id: event.id,
created_at: event.created_at,
summary: event.summary ?? `${event.action} on ${event.entity_type}`,
user: event.user_id ? userMap.get(event.user_id)?.name ?? userMap.get(event.user_id)?.email ?? "System" : "System"
}))}
/>
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Card,
CardContent,
Chip,
FormControlLabel,
MenuItem,
Stack,
TextField,
Typography,
Checkbox
} from "@mui/material";
import type { Certificate } from "@/src/lib/models/certificates";
import { createCertificateAction, deleteCertificateAction, updateCertificateAction } from "./actions";
type Props = {
certificates: Certificate[];
};
export default function CertificatesClient({ certificates }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Certificates
</Typography>
<Typography color="text.secondary">
Manage ACME-managed certificates or import your own PEM files for custom deployments.
</Typography>
</Stack>
<Stack spacing={3}>
{certificates.map((cert) => (
<Card key={cert.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{cert.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{cert.domain_names.join(", ")}
</Typography>
</Box>
<Chip label={cert.type === "managed" ? "Managed" : "Imported"} color="primary" variant="outlined" />
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateCertificateAction(cert.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={cert.name} fullWidth />
<TextField
name="domain_names"
label="Domains"
defaultValue={cert.domain_names.join("\n")}
multiline
minRows={3}
fullWidth
/>
<TextField select name="type" label="Type" defaultValue={cert.type} fullWidth>
<MenuItem value="managed">Managed (ACME)</MenuItem>
<MenuItem value="imported">Imported</MenuItem>
</TextField>
{cert.type === "managed" ? (
<Box>
<input type="hidden" name="auto_renew_present" value="1" />
<FormControlLabel control={<Checkbox name="auto_renew" defaultChecked={cert.auto_renew} />} label="Auto renew" />
</Box>
) : (
<>
<TextField
name="certificate_pem"
label="Certificate PEM"
placeholder="-----BEGIN CERTIFICATE-----"
multiline
minRows={6}
fullWidth
/>
<TextField
name="private_key_pem"
label="Private key PEM"
placeholder="-----BEGIN PRIVATE KEY-----"
multiline
minRows={6}
fullWidth
/>
</>
)}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button type="submit" variant="contained">
Save certificate
</Button>
<Button
type="submit"
formAction={deleteCertificateAction.bind(null, cert.id)}
variant="outlined"
color="error"
>
Delete
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Stack>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create certificate
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createCertificateAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Wildcard certificate" required fullWidth />
<TextField
name="domain_names"
label="Domains"
placeholder="example.com"
multiline
minRows={3}
required
fullWidth
/>
<TextField select name="type" label="Type" defaultValue="managed" fullWidth>
<MenuItem value="managed">Managed (ACME)</MenuItem>
<MenuItem value="imported">Imported</MenuItem>
</TextField>
<FormControlLabel control={<Checkbox name="auto_renew" defaultChecked />} label="Auto renew (managed only)" />
<TextField
name="certificate_pem"
label="Certificate PEM"
placeholder="Paste PEM content for imported certificates"
multiline
minRows={5}
fullWidth
/>
<TextField
name="private_key_pem"
label="Private key PEM"
placeholder="Paste PEM key for imported certificates"
multiline
minRows={5}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create certificate
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -16,7 +16,7 @@ function parseDomains(value: FormDataEntryValue | null): string[] {
}
export async function createCertificateAction(formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
const type = String(formData.get("type") ?? "managed") as "managed" | "imported";
await createCertificate(
{
@@ -33,7 +33,7 @@ export async function createCertificateAction(formData: FormData) {
}
export async function updateCertificateAction(id: number, formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
const type = formData.get("type") ? (String(formData.get("type")) as "managed" | "imported") : undefined;
await updateCertificate(
id,
@@ -51,7 +51,7 @@ export async function updateCertificateAction(id: number, formData: FormData) {
}
export async function deleteCertificateAction(id: number) {
const { user } = requireUser();
const { user } = await requireUser();
await deleteCertificate(id, user.id);
revalidatePath("/certificates");
}

View File

@@ -1,220 +1,7 @@
import CertificatesClient from "./CertificatesClient";
import { listCertificates } from "@/src/lib/models/certificates";
import { createCertificateAction, deleteCertificateAction, updateCertificateAction } from "./actions";
export default function CertificatesPage() {
const certificates = listCertificates();
return (
<div className="page">
<header>
<h1>Certificates</h1>
<p>Manage ACME-managed certificates or import your own PEM files for custom deployments.</p>
</header>
<section className="grid">
{certificates.map((cert) => (
<div className="card" key={cert.id}>
<details>
<summary>
<div className="summary">
<div>
<h2>{cert.name}</h2>
<p>{cert.domain_names.join(", ")}</p>
</div>
<span className="badge">{cert.type === "managed" ? "Managed" : "Imported"}</span>
</div>
</summary>
<form action={(formData) => updateCertificateAction(cert.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={cert.name} />
</label>
<label>
Domains
<textarea name="domain_names" defaultValue={cert.domain_names.join("\n")} rows={3} />
</label>
<label>
Type
<select name="type" defaultValue={cert.type}>
<option value="managed">Managed (ACME)</option>
<option value="imported">Imported</option>
</select>
</label>
{cert.type === "managed" ? (
<label className="toggle">
<input type="hidden" name="auto_renew_present" value="1" />
<input type="checkbox" name="auto_renew" defaultChecked={cert.auto_renew} /> Auto renew
</label>
) : (
<>
<label>
Certificate PEM
<textarea name="certificate_pem" placeholder="-----BEGIN CERTIFICATE-----" rows={6} />
</label>
<label>
Private key PEM
<textarea name="private_key_pem" placeholder="-----BEGIN PRIVATE KEY-----" rows={6} />
</label>
</>
)}
<div className="actions">
<button type="submit" className="primary">
Save certificate
</button>
</div>
</form>
</details>
<form action={() => deleteCertificateAction(cert.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create certificate</h2>
<form action={createCertificateAction} className="form">
<label>
Name
<input name="name" placeholder="Wildcard certificate" required />
</label>
<label>
Domains
<textarea name="domain_names" placeholder="example.com" rows={3} required />
</label>
<label>
Type
<select name="type" defaultValue="managed">
<option value="managed">Managed (ACME)</option>
<option value="imported">Imported</option>
</select>
</label>
<label className="toggle">
<input type="checkbox" name="auto_renew" defaultChecked /> Auto renew (managed only)
</label>
<label>
Certificate PEM
<textarea name="certificate_pem" placeholder="Paste PEM content for imported certificates" rows={5} />
</label>
<label>
Private key PEM
<textarea name="private_key_pem" placeholder="Paste PEM key for imported certificates" rows={5} />
</label>
<div className="actions">
<button type="submit" className="primary">
Create certificate
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.summary {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.summary h2 {
margin: 0 0 0.35rem;
}
.summary p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
.badge {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(0, 198, 255, 0.15);
color: #5be7ff;
}
details summary {
list-style: none;
cursor: pointer;
}
details summary::-webkit-details-marker {
display: none;
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input,
textarea,
select {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.toggle {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
align-self: flex-start;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
return <CertificatesClient certificates={certificates} />;
}

View File

@@ -0,0 +1,155 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Card,
CardContent,
Chip,
FormControlLabel,
Stack,
TextField,
Typography,
Checkbox
} from "@mui/material";
import type { DeadHost } from "@/src/lib/models/dead-hosts";
import { createDeadHostAction, deleteDeadHostAction, updateDeadHostAction } from "./actions";
type Props = {
hosts: DeadHost[];
};
export default function DeadHostsClient({ hosts }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Dead Hosts
</Typography>
<Typography color="text.secondary">Serve friendly status pages for domains without upstreams.</Typography>
</Stack>
<Stack spacing={3}>
{hosts.map((host) => (
<Card key={host.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{host.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{host.domains.join(", ")}
</Typography>
</Box>
<Chip
label={host.enabled ? "Enabled" : "Disabled"}
color={host.enabled ? "success" : "warning"}
variant={host.enabled ? "filled" : "outlined"}
/>
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateDeadHostAction(host.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={host.name} fullWidth />
<TextField
name="domains"
label="Domains"
defaultValue={host.domains.join("\n")}
multiline
minRows={2}
fullWidth
/>
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 599 }}
defaultValue={host.status_code}
fullWidth
/>
<TextField
name="response_body"
label="Response body"
defaultValue={host.response_body ?? ""}
multiline
minRows={3}
fullWidth
/>
<Box>
<input type="hidden" name="enabled_present" value="1" />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked={host.enabled} />} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
<Box component="form" action={deleteDeadHostAction.bind(null, host.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
))}
</Stack>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create dead host
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createDeadHostAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Maintenance page" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="offline.example.com"
multiline
minRows={2}
required
fullWidth
/>
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 599 }}
defaultValue={503}
fullWidth
/>
<TextField
name="response_body"
label="Response body"
placeholder="Service unavailable"
multiline
minRows={3}
fullWidth
/>
<FormControlLabel control={<Checkbox name="enabled" defaultChecked />} label="Enabled" />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Dead Host
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -16,7 +16,7 @@ function parseDomains(value: FormDataEntryValue | null): string[] {
}
export async function createDeadHostAction(formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await createDeadHost(
{
name: String(formData.get("name") ?? "Dead host"),
@@ -31,7 +31,7 @@ export async function createDeadHostAction(formData: FormData) {
}
export async function updateDeadHostAction(id: number, formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await updateDeadHost(
id,
{
@@ -47,7 +47,7 @@ export async function updateDeadHostAction(id: number, formData: FormData) {
}
export async function deleteDeadHostAction(id: number) {
const { user } = requireUser();
const { user } = await requireUser();
await deleteDeadHost(id, user.id);
revalidatePath("/dead-hosts");
}

View File

@@ -1,201 +1,7 @@
import DeadHostsClient from "./DeadHostsClient";
import { listDeadHosts } from "@/src/lib/models/dead-hosts";
import { createDeadHostAction, deleteDeadHostAction, updateDeadHostAction } from "./actions";
export default function DeadHostsPage() {
const hosts = listDeadHosts();
return (
<div className="page">
<header>
<h1>Dead Hosts</h1>
<p>Serve friendly status pages for domains without upstreams.</p>
</header>
<section className="grid">
{hosts.map((host) => (
<div className="card" key={host.id}>
<div className="header">
<div>
<h2>{host.name}</h2>
<p>{host.domains.join(", ")}</p>
</div>
<span className={host.enabled ? "status online" : "status offline"}>{host.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit</summary>
<form action={(formData) => updateDeadHostAction(host.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={host.name} />
</label>
<label>
Domains
<textarea name="domains" defaultValue={host.domains.join("\n")} rows={2} />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={host.status_code} min={200} max={599} />
</label>
<label>
Response body (optional)
<textarea name="response_body" defaultValue={host.response_body ?? ""} rows={3} />
</label>
<label className="toggle">
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={host.enabled} /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Save
</button>
</div>
</form>
</details>
<form action={() => deleteDeadHostAction(host.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create dead host</h2>
<form action={createDeadHostAction} className="form">
<label>
Name
<input name="name" placeholder="Maintenance page" required />
</label>
<label>
Domains
<textarea name="domains" placeholder="offline.example.com" rows={2} required />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={503} min={200} max={599} />
</label>
<label>
Response body
<textarea name="response_body" placeholder="Service unavailable" rows={3} />
</label>
<label className="toggle">
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Create Dead Host
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header h2 {
margin: 0 0 0.35rem;
}
.header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.toggle {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
input,
textarea {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
return <DeadHostsClient hosts={hosts} />;
}

View File

@@ -1,63 +1,8 @@
import { ReactNode } from "react";
import type { ReactNode } from "react";
import { requireUser } from "@/src/lib/auth/session";
import { NavLinks } from "./nav-links";
import DashboardLayoutClient from "./DashboardLayoutClient";
export default function DashboardLayout({ children }: { children: ReactNode }) {
const { user } = requireUser();
return (
<div className="layout">
<aside>
<div className="brand">
<h2>Caddy Proxy Manager</h2>
<span className="user">{user.name ?? user.email}</span>
</div>
<NavLinks />
<form action="/api/auth/logout" method="POST" className="logout">
<button type="submit">Sign out</button>
</form>
</aside>
<main>{children}</main>
<style jsx>{`
.layout {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
aside {
padding: 2rem 1.75rem;
background: linear-gradient(180deg, #101523 0%, #080b14 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 2rem;
}
.brand h2 {
margin: 0;
font-size: 1.1rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.user {
display: block;
margin-top: 0.5rem;
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
}
.logout button {
width: 100%;
padding: 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.75);
border: none;
cursor: pointer;
}
main {
padding: 2.5rem 3rem;
background: radial-gradient(circle at top left, rgba(0, 114, 255, 0.15), transparent 55%);
}
`}</style>
</div>
);
export default async function DashboardLayout({ children }: { children: ReactNode }) {
const { user } = await requireUser();
return <DashboardLayoutClient user={user}>{children}</DashboardLayoutClient>;
}

View File

@@ -1,50 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const NAV_LINKS = [
{ href: "/", label: "Overview" },
{ href: "/proxy-hosts", label: "Proxy Hosts" },
{ href: "/redirects", label: "Redirects" },
{ href: "/dead-hosts", label: "Dead Hosts" },
{ href: "/streams", label: "Streams" },
{ href: "/access-lists", label: "Access Lists" },
{ href: "/certificates", label: "Certificates" },
{ href: "/settings", label: "Settings" },
{ href: "/audit-log", label: "Audit Log" }
];
export function NavLinks() {
const pathname = usePathname();
return (
<nav>
{NAV_LINKS.map((link) => {
const isActive = pathname === link.href;
return (
<Link href={link.href} key={link.href} className={`nav-link${isActive ? " active" : ""}`}>
{link.label}
</Link>
);
})}
<style jsx>{`
nav {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.nav-link {
padding: 0.6rem 0.9rem;
border-radius: 10px;
color: rgba(255, 255, 255, 0.75);
transition: background 0.2s ease, color 0.2s ease;
}
.nav-link:hover,
.nav-link.active {
background: rgba(0, 198, 255, 0.15);
color: #fff;
}
`}</style>
</nav>
);
}

View File

@@ -1,6 +1,6 @@
import Link from "next/link";
import db from "@/src/lib/db";
import { requireUser } from "@/src/lib/auth/session";
import OverviewClient from "./OverviewClient";
type StatCard = {
label: string;
@@ -11,42 +11,12 @@ type StatCard = {
function loadStats(): StatCard[] {
const metrics = [
{
label: "Proxy Hosts",
table: "proxy_hosts",
href: "/proxy-hosts",
icon: "⇄"
},
{
label: "Redirects",
table: "redirect_hosts",
href: "/redirects",
icon: "↪"
},
{
label: "Dead Hosts",
table: "dead_hosts",
href: "/dead-hosts",
icon: "☠"
},
{
label: "Streams",
table: "stream_hosts",
href: "/streams",
icon: "≋"
},
{
label: "Certificates",
table: "certificates",
href: "/certificates",
icon: "🔐"
},
{
label: "Access Lists",
table: "access_lists",
href: "/access-lists",
icon: "🔒"
}
{ label: "Proxy Hosts", table: "proxy_hosts", href: "/proxy-hosts", icon: "⇄" },
{ label: "Redirects", table: "redirect_hosts", href: "/redirects", icon: "↪" },
{ label: "Dead Hosts", table: "dead_hosts", href: "/dead-hosts", icon: "☠" },
{ label: "Streams", table: "stream_hosts", href: "/streams", icon: "≋" },
{ label: "Certificates", table: "certificates", href: "/certificates", icon: "🔐" },
{ label: "Access Lists", table: "access_lists", href: "/access-lists", icon: "🔒" }
] as const;
return metrics.map((metric) => {
@@ -60,10 +30,9 @@ function loadStats(): StatCard[] {
});
}
export default function OverviewPage() {
const { user } = requireUser();
export default async function OverviewPage() {
const { user } = await requireUser();
const stats = loadStats();
const recentEvents = db
.prepare(
`SELECT action, entity_type, summary, created_at
@@ -74,110 +43,13 @@ export default function OverviewPage() {
.all() as { action: string; entity_type: string; summary: string | null; created_at: string }[];
return (
<div className="overview">
<header>
<h1>Welcome back, {user.name ?? user.email}</h1>
<p>Manage your Caddy reverse proxies, TLS certificates, and services with confidence.</p>
</header>
<section className="stats">
{stats.map((stat) => (
<Link className="card" href={stat.href} key={stat.label}>
<span className="icon">{stat.icon}</span>
<span className="value">{stat.count}</span>
<span className="label">{stat.label}</span>
</Link>
))}
</section>
<section className="events">
<h2>Recent Activity</h2>
{recentEvents.length === 0 ? (
<p className="empty">No activity recorded yet.</p>
) : (
<ul>
{recentEvents.map((event, index) => (
<li key={index}>
<span className="summary">{event.summary ?? `${event.action} on ${event.entity_type}`}</span>
<span className="time">{new Date(event.created_at).toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
<style jsx>{`
.overview {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header h1 {
margin: 0;
font-size: 2rem;
}
header p {
margin: 0.75rem 0 0;
color: rgba(255, 255, 255, 0.65);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1.5rem;
border-radius: 16px;
background: rgba(16, 26, 45, 0.9);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: transform 0.2s ease, border-color 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
border-color: rgba(0, 198, 255, 0.45);
}
.icon {
font-size: 1.35rem;
opacity: 0.65;
}
.value {
font-size: 2.1rem;
font-weight: 600;
}
.label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.95rem;
}
.events h2 {
margin: 0 0 1rem;
}
.events ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.events li {
display: flex;
justify-content: space-between;
padding: 1rem 1.25rem;
border-radius: 12px;
background: rgba(16, 26, 45, 0.8);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.summary {
font-weight: 500;
}
.time {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.empty {
color: rgba(255, 255, 255, 0.55);
}
`}</style>
</div>
<OverviewClient
userName={user.name ?? user.email}
stats={stats}
recentEvents={recentEvents.map((event) => ({
summary: event.summary ?? `${event.action} on ${event.entity_type}`,
created_at: event.created_at
}))}
/>
);
}

View File

@@ -0,0 +1,217 @@
"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 type { AccessList } from "@/src/lib/models/access-lists";
import type { Certificate } from "@/src/lib/models/certificates";
import type { ProxyHost } from "@/src/lib/models/proxy-hosts";
import { createProxyHostAction, deleteProxyHostAction, updateProxyHostAction } from "./actions";
type Props = {
hosts: ProxyHost[];
certificates: Certificate[];
accessLists: AccessList[];
};
export default function ProxyHostsClient({ hosts, certificates, accessLists }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Proxy Hosts
</Typography>
<Typography color="text.secondary">
Define HTTP(S) reverse proxies managed by Caddy with built-in TLS orchestration.
</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}>
{host.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{host.domains.join(", ")}
</Typography>
</Box>
<Chip
label={host.enabled ? "Enabled" : "Disabled"}
color={host.enabled ? "success" : "warning"}
variant={host.enabled ? "filled" : "outlined"}
/>
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit configuration</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateProxyHostAction(host.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={host.name} required fullWidth />
<TextField
name="domains"
label="Domains"
helperText="Comma or newline separated"
defaultValue={host.domains.join("\n")}
multiline
minRows={3}
fullWidth
/>
<TextField
name="upstreams"
label="Upstreams"
helperText="Comma or newline separated"
defaultValue={host.upstreams.join("\n")}
multiline
minRows={3}
fullWidth
/>
<TextField select name="certificate_id" label="Certificate" defaultValue={host.certificate_id ?? ""} fullWidth>
<MenuItem value="">Managed by Caddy</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>
<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" />
<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"
/>
<HiddenCheckboxField name="enabled" defaultChecked={host.enabled} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1.5 }}>
<Button type="submit" variant="contained">
Save Changes
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
<Box component="form" action={deleteProxyHostAction.bind(null, host.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create proxy host
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createProxyHostAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Internal service" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="app.example.com"
multiline
minRows={2}
required
fullWidth
/>
<TextField
name="upstreams"
label="Upstreams"
placeholder="http://10.0.0.5:8080"
multiline
minRows={2}
required
fullWidth
/>
<TextField select name="certificate_id" label="Certificate" defaultValue="" fullWidth>
<MenuItem value="">Managed by Caddy</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>
<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>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Host
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}
function HiddenCheckboxField({ name, defaultChecked, label }: { name: string; defaultChecked: boolean; label: string }) {
return (
<Box>
<input type="hidden" name={`${name}_present`} value="1" />
<FormControlLabel control={<Checkbox name={name} defaultChecked={defaultChecked} />} label={label} />
</Box>
);
}

View File

@@ -20,7 +20,7 @@ function parseCheckbox(value: FormDataEntryValue | null): boolean {
}
export async function createProxyHostAction(formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await createProxyHost(
{
name: String(formData.get("name") ?? "Untitled"),
@@ -41,7 +41,7 @@ export async function createProxyHostAction(formData: FormData) {
}
export async function updateProxyHostAction(id: number, formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
const boolField = (key: string) => (formData.has(`${key}_present`) ? parseCheckbox(formData.get(key)) : undefined);
await updateProxyHost(
id,
@@ -64,7 +64,7 @@ export async function updateProxyHostAction(id: number, formData: FormData) {
}
export async function deleteProxyHostAction(id: number) {
const { user } = requireUser();
const { user } = await requireUser();
await deleteProxyHost(id, user.id);
revalidatePath("/proxy-hosts");
}

View File

@@ -1,4 +1,4 @@
import { createProxyHostAction, deleteProxyHostAction, updateProxyHostAction } from "./actions";
import ProxyHostsClient from "./ProxyHostsClient";
import { listProxyHosts } from "@/src/lib/models/proxy-hosts";
import { listCertificates } from "@/src/lib/models/certificates";
import { listAccessLists } from "@/src/lib/models/access-lists";
@@ -8,283 +8,5 @@ export default function ProxyHostsPage() {
const certificates = listCertificates();
const accessLists = listAccessLists();
return (
<div className="page">
<header>
<h1>Proxy Hosts</h1>
<p>Define HTTP(S) reverse proxies managed by Caddy with built-in TLS orchestration.</p>
</header>
<section className="grid">
{hosts.map((host) => (
<div className="host-card" key={host.id}>
<div className="host-header">
<div>
<h2>{host.name}</h2>
<p>{host.domains.join(", ")}</p>
</div>
<span className={host.enabled ? "status online" : "status offline"}>{host.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit configuration</summary>
<form action={(formData) => updateProxyHostAction(host.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={host.name} required />
</label>
<label>
Domains (comma or newline separated)
<textarea name="domains" defaultValue={host.domains.join("\n")} required rows={3} />
</label>
<label>
Upstreams (e.g. 127.0.0.1:3000)
<textarea name="upstreams" defaultValue={host.upstreams.join("\n")} required rows={3} />
</label>
<label>
Certificate
<select name="certificate_id" defaultValue={host.certificate_id ?? ""}>
<option value="">Managed by Caddy</option>
{certificates.map((cert) => (
<option value={cert.id} key={cert.id}>
{cert.name}
</option>
))}
</select>
</label>
<label>
Access List
<select name="access_list_id" defaultValue={host.access_list_id ?? ""}>
<option value="">None</option>
{accessLists.map((list) => (
<option value={list.id} key={list.id}>
{list.name}
</option>
))}
</select>
</label>
<div className="toggles">
<label>
<input type="hidden" name="ssl_forced_present" value="1" />
<input type="checkbox" name="ssl_forced" defaultChecked={host.ssl_forced} /> Force HTTPS
</label>
<label>
<input type="hidden" name="hsts_enabled_present" value="1" />
<input type="checkbox" name="hsts_enabled" defaultChecked={host.hsts_enabled} /> HSTS
</label>
<label>
<input type="hidden" name="hsts_subdomains_present" value="1" />
<input type="checkbox" name="hsts_subdomains" defaultChecked={host.hsts_subdomains} /> Include subdomains in HSTS
</label>
<label>
<input type="hidden" name="allow_websocket_present" value="1" />
<input type="checkbox" name="allow_websocket" defaultChecked={host.allow_websocket} /> Allow WebSocket
</label>
<label>
<input type="hidden" name="preserve_host_header_present" value="1" />
<input type="checkbox" name="preserve_host_header" defaultChecked={host.preserve_host_header} /> Preserve host header
</label>
<label>
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={host.enabled} /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save Changes
</button>
</div>
</form>
</details>
<form action={() => deleteProxyHostAction(host.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section>
<h2>Create proxy host</h2>
<form action={createProxyHostAction} className="form create">
<label>
Name
<input name="name" placeholder="Internal service" required />
</label>
<label>
Domains
<textarea name="domains" placeholder="app.example.com" rows={2} required />
</label>
<label>
Upstreams
<textarea name="upstreams" placeholder="http://10.0.0.5:8080" rows={2} required />
</label>
<label>
Certificate
<select name="certificate_id" defaultValue="">
<option value="">Managed by Caddy</option>
{certificates.map((cert) => (
<option value={cert.id} key={cert.id}>
{cert.name}
</option>
))}
</select>
</label>
<label>
Access List
<select name="access_list_id" defaultValue="">
<option value="">None</option>
{accessLists.map((list) => (
<option value={list.id} key={list.id}>
{list.name}
</option>
))}
</select>
</label>
<div className="toggles">
<label>
<input type="checkbox" name="ssl_forced" defaultChecked /> Force HTTPS
</label>
<label>
<input type="checkbox" name="hsts_enabled" defaultChecked /> HSTS
</label>
<label>
<input type="checkbox" name="allow_websocket" defaultChecked /> Allow WebSocket
</label>
<label>
<input type="checkbox" name="preserve_host_header" defaultChecked /> Preserve host header
</label>
<label>
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Create Host
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header h1 {
margin: 0;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.host-card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.host-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.host-header h2 {
margin: 0 0 0.4rem;
}
.host-header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
}
input,
textarea,
select {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.toggles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.6rem;
}
.toggles label {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.primary {
padding: 0.65rem 1.5rem;
border-radius: 999px;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
font-weight: 600;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
return <ProxyHostsClient hosts={hosts} certificates={certificates} accessLists={accessLists} />;
}

View File

@@ -0,0 +1,157 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Card,
CardContent,
Chip,
FormControlLabel,
Stack,
TextField,
Typography,
Checkbox
} from "@mui/material";
import type { RedirectHost } from "@/src/lib/models/redirect-hosts";
import { createRedirectAction, deleteRedirectAction, updateRedirectAction } from "./actions";
type Props = {
redirects: RedirectHost[];
};
export default function RedirectsClient({ redirects }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Redirects
</Typography>
<Typography color="text.secondary">Return HTTP 301/302 responses to guide clients toward canonical hosts.</Typography>
</Stack>
<Stack spacing={3}>
{redirects.map((redirect) => (
<Card key={redirect.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{redirect.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{redirect.domains.join(", ")}
</Typography>
</Box>
<Chip
label={redirect.enabled ? "Enabled" : "Disabled"}
color={redirect.enabled ? "success" : "warning"}
variant={redirect.enabled ? "filled" : "outlined"}
/>
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateRedirectAction(redirect.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={redirect.name} fullWidth />
<TextField
name="domains"
label="Domains"
defaultValue={redirect.domains.join("\n")}
multiline
minRows={2}
fullWidth
/>
<TextField name="destination" label="Destination URL" defaultValue={redirect.destination} fullWidth />
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 399 }}
defaultValue={redirect.status_code}
fullWidth
/>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 1 }}>
<HiddenCheckboxField
name="preserve_query"
defaultChecked={redirect.preserve_query}
label="Preserve path/query"
/>
<HiddenCheckboxField name="enabled" defaultChecked={redirect.enabled} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
<Box component="form" action={deleteRedirectAction.bind(null, redirect.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
))}
</Stack>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create redirect
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createRedirectAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Example redirect" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="old.example.com"
multiline
minRows={2}
required
fullWidth
/>
<TextField name="destination" label="Destination URL" placeholder="https://new.example.com" required fullWidth />
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 399 }}
defaultValue={302}
fullWidth
/>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 1 }}>
<FormControlLabel control={<Checkbox name="preserve_query" defaultChecked />} label="Preserve path/query" />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked />} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Redirect
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}
function HiddenCheckboxField({ name, defaultChecked, label }: { name: string; defaultChecked: boolean; label: string }) {
return (
<Box>
<input type="hidden" name={`${name}_present`} value="1" />
<FormControlLabel control={<Checkbox name={name} defaultChecked={defaultChecked} />} label={label} />
</Box>
);
}

View File

@@ -16,7 +16,7 @@ function parseList(value: FormDataEntryValue | null): string[] {
}
export async function createRedirectAction(formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await createRedirectHost(
{
name: String(formData.get("name") ?? "Redirect"),
@@ -32,7 +32,7 @@ export async function createRedirectAction(formData: FormData) {
}
export async function updateRedirectAction(id: number, formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await updateRedirectHost(
id,
{
@@ -49,7 +49,7 @@ export async function updateRedirectAction(id: number, formData: FormData) {
}
export async function deleteRedirectAction(id: number) {
const { user } = requireUser();
const { user } = await requireUser();
await deleteRedirectHost(id, user.id);
revalidatePath("/redirects");
}

View File

@@ -1,217 +1,7 @@
import RedirectsClient from "./RedirectsClient";
import { listRedirectHosts } from "@/src/lib/models/redirect-hosts";
import { createRedirectAction, deleteRedirectAction, updateRedirectAction } from "./actions";
export default function RedirectsPage() {
const redirects = listRedirectHosts();
return (
<div className="page">
<header>
<h1>Redirects</h1>
<p>Return HTTP 301/302 responses to guide clients toward canonical hosts.</p>
</header>
<section className="grid">
{redirects.map((redirect) => (
<div className="card" key={redirect.id}>
<div className="header">
<div>
<h2>{redirect.name}</h2>
<p>{redirect.domains.join(", ")}</p>
</div>
<span className={redirect.enabled ? "status online" : "status offline"}>{redirect.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit</summary>
<form action={(formData) => updateRedirectAction(redirect.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={redirect.name} />
</label>
<label>
Domains
<textarea name="domains" defaultValue={redirect.domains.join("\n")} rows={2} />
</label>
<label>
Destination URL
<input name="destination" defaultValue={redirect.destination} />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={redirect.status_code} min={200} max={399} />
</label>
<div className="toggles">
<label>
<input type="hidden" name="preserve_query_present" value="1" />
<input type="checkbox" name="preserve_query" defaultChecked={redirect.preserve_query} /> Preserve path/query
</label>
<label>
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={redirect.enabled} /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save
</button>
</div>
</form>
</details>
<form action={() => deleteRedirectAction(redirect.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create redirect</h2>
<form action={createRedirectAction} className="form">
<label>
Name
<input name="name" placeholder="Example redirect" required />
</label>
<label>
Domains
<textarea name="domains" placeholder="old.example.com" rows={2} required />
</label>
<label>
Destination URL
<input name="destination" placeholder="https://new.example.com" required />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={302} min={200} max={399} />
</label>
<div className="toggles">
<label>
<input type="checkbox" name="preserve_query" defaultChecked /> Preserve path/query
</label>
<label>
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Create Redirect
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header h2 {
margin: 0 0 0.35rem;
}
.header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input,
textarea {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.toggles {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.toggles label {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
return <RedirectsClient redirects={redirects} />;
}

View File

@@ -0,0 +1,108 @@
"use client";
import { Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material";
import type { CloudflareSettings, GeneralSettings, OAuthSettings } from "@/src/lib/settings";
import {
updateCloudflareSettingsAction,
updateGeneralSettingsAction,
updateOAuthSettingsAction
} from "./actions";
type Props = {
general: GeneralSettings | null;
oauth: OAuthSettings | null;
cloudflare: CloudflareSettings | null;
};
export default function SettingsClient({ general, oauth, cloudflare }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Settings
</Typography>
<Typography color="text.secondary">Configure organization-wide defaults, authentication, and DNS automation.</Typography>
</Stack>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
General
</Typography>
<Stack component="form" action={updateGeneralSettingsAction} spacing={2}>
<TextField
name="primaryDomain"
label="Primary domain"
defaultValue={general?.primaryDomain ?? "caddyproxymanager.com"}
required
fullWidth
/>
<TextField
name="acmeEmail"
label="ACME contact email"
type="email"
defaultValue={general?.acmeEmail ?? ""}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save general settings
</Button>
</Box>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
OAuth2 Authentication
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Provide the OAuth 2.0 endpoints and client credentials issued by your identity provider. Scopes should include profile and
email data.
</Typography>
<Stack component="form" action={updateOAuthSettingsAction} spacing={2}>
<TextField name="authorizationUrl" label="Authorization URL" defaultValue={oauth?.authorizationUrl ?? ""} required fullWidth />
<TextField name="tokenUrl" label="Token URL" defaultValue={oauth?.tokenUrl ?? ""} required fullWidth />
<TextField name="userInfoUrl" label="User info URL" defaultValue={oauth?.userInfoUrl ?? ""} required fullWidth />
<TextField name="clientId" label="Client ID" defaultValue={oauth?.clientId ?? ""} required fullWidth />
<TextField name="clientSecret" label="Client secret" defaultValue={oauth?.clientSecret ?? ""} required fullWidth />
<TextField name="scopes" label="Scopes" defaultValue={oauth?.scopes ?? "openid email profile"} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField name="emailClaim" label="Email claim" defaultValue={oauth?.emailClaim ?? "email"} fullWidth />
<TextField name="nameClaim" label="Name claim" defaultValue={oauth?.nameClaim ?? "name"} fullWidth />
<TextField name="avatarClaim" label="Avatar claim" defaultValue={oauth?.avatarClaim ?? "picture"} fullWidth />
</Stack>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save OAuth settings
</Button>
</Box>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
Cloudflare DNS
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates.
</Typography>
<Stack component="form" action={updateCloudflareSettingsAction} spacing={2}>
<TextField name="apiToken" label="API token" defaultValue={cloudflare?.apiToken ?? ""} fullWidth />
<TextField name="zoneId" label="Zone ID" defaultValue={cloudflare?.zoneId ?? ""} fullWidth />
<TextField name="accountId" label="Account ID" defaultValue={cloudflare?.accountId ?? ""} fullWidth />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save Cloudflare settings
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
);
}

View File

@@ -6,7 +6,7 @@ import { applyCaddyConfig } from "@/src/lib/caddy";
import { saveCloudflareSettings, saveGeneralSettings, saveOAuthSettings } from "@/src/lib/settings";
export async function updateGeneralSettingsAction(formData: FormData) {
requireUser(); // ensure authenticated
await requireUser(); // ensure authenticated
saveGeneralSettings({
primaryDomain: String(formData.get("primaryDomain") ?? ""),
acmeEmail: formData.get("acmeEmail") ? String(formData.get("acmeEmail")) : undefined
@@ -15,7 +15,7 @@ export async function updateGeneralSettingsAction(formData: FormData) {
}
export async function updateOAuthSettingsAction(formData: FormData) {
requireUser();
await requireUser();
saveOAuthSettings({
authorizationUrl: String(formData.get("authorizationUrl") ?? ""),
tokenUrl: String(formData.get("tokenUrl") ?? ""),
@@ -31,7 +31,7 @@ export async function updateOAuthSettingsAction(formData: FormData) {
}
export async function updateCloudflareSettingsAction(formData: FormData) {
requireUser();
await requireUser();
const apiToken = String(formData.get("apiToken") ?? "");
if (!apiToken) {
saveCloudflareSettings({ apiToken: "", zoneId: undefined, accountId: undefined });

View File

@@ -1,180 +1,12 @@
import SettingsClient from "./SettingsClient";
import { getCloudflareSettings, getGeneralSettings, getOAuthSettings } from "@/src/lib/settings";
import { updateCloudflareSettingsAction, updateGeneralSettingsAction, updateOAuthSettingsAction } from "./actions";
export default function SettingsPage() {
const general = getGeneralSettings();
const oauth = getOAuthSettings();
const cloudflare = getCloudflareSettings();
return (
<div className="page">
<header>
<h1>Settings</h1>
<p>Configure organization-wide defaults, authentication, and DNS automation.</p>
</header>
<section className="panel">
<h2>General</h2>
<form action={updateGeneralSettingsAction} className="form">
<label>
Primary domain
<input name="primaryDomain" defaultValue={general?.primaryDomain ?? "caddyproxymanager.com"} required />
</label>
<label>
ACME contact email
<input type="email" name="acmeEmail" defaultValue={general?.acmeEmail ?? ""} placeholder="admin@example.com" />
</label>
<div className="actions">
<button type="submit" className="primary">
Save general settings
</button>
</div>
</form>
</section>
<section className="panel">
<h2>OAuth2 Authentication</h2>
<p className="help">
Provide the OAuth 2.0 endpoints and client credentials issued by your identity provider. Scopes should include profile and email
data.
</p>
<form action={updateOAuthSettingsAction} className="form">
<label>
Authorization URL
<input name="authorizationUrl" defaultValue={oauth?.authorizationUrl ?? ""} required />
</label>
<label>
Token URL
<input name="tokenUrl" defaultValue={oauth?.tokenUrl ?? ""} required />
</label>
<label>
User info URL
<input name="userInfoUrl" defaultValue={oauth?.userInfoUrl ?? ""} required />
</label>
<label>
Client ID
<input name="clientId" defaultValue={oauth?.clientId ?? ""} required />
</label>
<label>
Client secret
<input name="clientSecret" defaultValue={oauth?.clientSecret ?? ""} required />
</label>
<label>
Scopes
<input name="scopes" defaultValue={oauth?.scopes ?? "openid email profile"} />
</label>
<div className="stack">
<label>
Email claim
<input name="emailClaim" defaultValue={oauth?.emailClaim ?? "email"} />
</label>
<label>
Name claim
<input name="nameClaim" defaultValue={oauth?.nameClaim ?? "name"} />
</label>
<label>
Avatar claim
<input name="avatarClaim" defaultValue={oauth?.avatarClaim ?? "picture"} />
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save OAuth settings
</button>
</div>
</form>
</section>
<section className="panel">
<h2>Cloudflare DNS</h2>
<p className="help">
Configure a Cloudflare API token with <code>Zone.DNS Edit</code> permissions to enable DNS-01 challenges for wildcard certificates.
</p>
<form action={updateCloudflareSettingsAction} className="form">
<label>
API token
<input name="apiToken" defaultValue={cloudflare?.apiToken ?? ""} placeholder="CF_API_TOKEN" />
</label>
<label>
Zone ID
<input name="zoneId" defaultValue={cloudflare?.zoneId ?? ""} />
</label>
<label>
Account ID
<input name="accountId" defaultValue={cloudflare?.accountId ?? ""} />
</label>
<div className="actions">
<button type="submit" className="primary">
Save Cloudflare settings
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.panel {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.stack {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.help {
margin: 0;
color: rgba(255, 255, 255, 0.55);
font-size: 0.9rem;
}
code {
background: rgba(255, 255, 255, 0.1);
padding: 0.1rem 0.35rem;
border-radius: 6px;
font-size: 0.85rem;
}
`}</style>
</div>
<SettingsClient
general={getGeneralSettings()}
oauth={getOAuthSettings()}
cloudflare={getCloudflareSettings()}
/>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Card,
CardContent,
Chip,
FormControlLabel,
MenuItem,
Stack,
TextField,
Typography,
Checkbox
} from "@mui/material";
import type { StreamHost } from "@/src/lib/models/stream-hosts";
import { createStreamAction, deleteStreamAction, updateStreamAction } from "./actions";
type Props = {
streams: StreamHost[];
};
export default function StreamsClient({ streams }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Streams
</Typography>
<Typography color="text.secondary">Forward raw TCP/UDP connections through Caddy&apos;s layer4 module.</Typography>
</Stack>
<Stack spacing={3}>
{streams.map((stream) => (
<Card key={stream.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{stream.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Listens on :{stream.listen_port} ({stream.protocol.toUpperCase()}) {stream.upstream}
</Typography>
</Box>
<Chip
label={stream.enabled ? "Enabled" : "Disabled"}
color={stream.enabled ? "success" : "warning"}
variant={stream.enabled ? "filled" : "outlined"}
/>
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateStreamAction(stream.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={stream.name} fullWidth />
<TextField
name="listen_port"
label="Listen port"
type="number"
inputProps={{ min: 1, max: 65535 }}
defaultValue={stream.listen_port}
fullWidth
/>
<TextField select name="protocol" label="Protocol" defaultValue={stream.protocol} fullWidth>
<MenuItem value="tcp">TCP</MenuItem>
<MenuItem value="udp">UDP</MenuItem>
</TextField>
<TextField name="upstream" label="Upstream" defaultValue={stream.upstream} fullWidth />
<Box>
<input type="hidden" name="enabled_present" value="1" />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked={stream.enabled} />} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
<Box component="form" action={deleteStreamAction.bind(null, stream.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
))}
</Stack>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create stream
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createStreamAction} spacing={2}>
<TextField name="name" label="Name" placeholder="SSH tunnel" required fullWidth />
<TextField
name="listen_port"
label="Listen port"
type="number"
inputProps={{ min: 1, max: 65535 }}
placeholder="2222"
required
fullWidth
/>
<TextField select name="protocol" label="Protocol" defaultValue="tcp" fullWidth>
<MenuItem value="tcp">TCP</MenuItem>
<MenuItem value="udp">UDP</MenuItem>
</TextField>
<TextField name="upstream" label="Upstream" placeholder="10.0.0.12:22" required fullWidth />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked />} label="Enabled" />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Stream
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -5,7 +5,7 @@ import { requireUser } from "@/src/lib/auth/session";
import { createStreamHost, deleteStreamHost, updateStreamHost } from "@/src/lib/models/stream-hosts";
export async function createStreamAction(formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await createStreamHost(
{
name: String(formData.get("name") ?? "Stream"),
@@ -20,7 +20,7 @@ export async function createStreamAction(formData: FormData) {
}
export async function updateStreamAction(id: number, formData: FormData) {
const { user } = requireUser();
const { user } = await requireUser();
await updateStreamHost(
id,
{
@@ -36,7 +36,7 @@ export async function updateStreamAction(id: number, formData: FormData) {
}
export async function deleteStreamAction(id: number) {
const { user } = requireUser();
const { user } = await requireUser();
await deleteStreamHost(id, user.id);
revalidatePath("/streams");
}

View File

@@ -1,209 +1,7 @@
import StreamsClient from "./StreamsClient";
import { listStreamHosts } from "@/src/lib/models/stream-hosts";
import { createStreamAction, deleteStreamAction, updateStreamAction } from "./actions";
export default function StreamsPage() {
const streams = listStreamHosts();
return (
<div className="page">
<header>
<h1>Streams</h1>
<p>Forward raw TCP/UDP connections through Caddy&apos;s layer4 module.</p>
</header>
<section className="grid">
{streams.map((stream) => (
<div className="card" key={stream.id}>
<div className="header">
<div>
<h2>{stream.name}</h2>
<p>
Listens on :{stream.listen_port} ({stream.protocol.toUpperCase()}) {stream.upstream}
</p>
</div>
<span className={stream.enabled ? "status online" : "status offline"}>{stream.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit</summary>
<form action={(formData) => updateStreamAction(stream.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={stream.name} />
</label>
<label>
Listen port
<input type="number" name="listen_port" defaultValue={stream.listen_port} min={1} max={65535} />
</label>
<label>
Protocol
<select name="protocol" defaultValue={stream.protocol}>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</label>
<label>
Upstream
<input name="upstream" defaultValue={stream.upstream} />
</label>
<label className="toggle">
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={stream.enabled} /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Save
</button>
</div>
</form>
</details>
<form action={() => deleteStreamAction(stream.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create stream</h2>
<form action={createStreamAction} className="form">
<label>
Name
<input name="name" placeholder="SSH tunnel" required />
</label>
<label>
Listen port
<input type="number" name="listen_port" placeholder="2222" min={1} max={65535} required />
</label>
<label>
Protocol
<select name="protocol" defaultValue="tcp">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</label>
<label>
Upstream
<input name="upstream" placeholder="10.0.0.12:22" required />
</label>
<label className="toggle">
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Create Stream
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header h2 {
margin: 0 0 0.35rem;
}
.header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.toggle {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
input,
select {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
return <StreamsClient streams={streams} />;
}

View File

@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
try {
const { user, redirectTo } = await finalizeOAuthLogin(code, state);
createSession(user.id);
await createSession(user.id);
const destination = redirectTo && redirectTo.startsWith("/") ? redirectTo : "/";
return NextResponse.redirect(new URL(destination, config.baseUrl));
} catch (error) {

View File

@@ -3,6 +3,6 @@ import { destroySession } from "@/src/lib/auth/session";
import { config } from "@/src/lib/config";
export async function POST() {
destroySession();
await destroySession();
return NextResponse.redirect(new URL("/login", config.baseUrl));
}

View File

@@ -1,12 +1,13 @@
"use client";
import type { ReactNode } from "react";
import "./globals.css";
import { ReactNode } from "react";
import Providers from "./providers";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

52
app/providers.tsx Normal file
View File

@@ -0,0 +1,52 @@
"use client";
import { ReactNode, useMemo } from "react";
import { CssBaseline, ThemeProvider, createTheme, responsiveFontSizes } from "@mui/material";
export default function Providers({ children }: { children: ReactNode }) {
const theme = useMemo(
() =>
responsiveFontSizes(
createTheme({
palette: {
mode: "dark",
background: {
default: "#05070c",
paper: "#0c111d"
},
primary: {
main: "#00bcd4"
}
},
shape: {
borderRadius: 12
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: "none",
borderRadius: 999
}
}
},
MuiCard: {
styleOverrides: {
root: {
backgroundImage: "none"
}
}
}
}
})
),
[]
);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { Box, Button, Card, CardContent, Grid, Stack, TextField, Typography } from "@mui/material";
export default function OAuthSetupClient({ startSetup }: { startSetup: (formData: FormData) => void }) {
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", bgcolor: "background.default" }}>
<Card sx={{ width: { xs: "90vw", sm: 640 }, p: { xs: 2, sm: 3 } }}>
<CardContent>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Configure OAuth2
</Typography>
<Typography color="text.secondary">
Provide the OAuth configuration for your identity provider to finish setting up Caddy Proxy Manager. The first user who
signs in becomes the administrator.
</Typography>
</Stack>
<Stack component="form" action={startSetup} spacing={2}>
<TextField
name="authorizationUrl"
label="Authorization URL"
placeholder="https://id.example.com/oauth2/authorize"
required
fullWidth
/>
<TextField name="tokenUrl" label="Token URL" placeholder="https://id.example.com/oauth2/token" required fullWidth />
<TextField
name="userInfoUrl"
label="User info URL"
placeholder="https://id.example.com/oauth2/userinfo"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" placeholder="client-id" required fullWidth />
<TextField name="clientSecret" label="Client secret" placeholder="client-secret" required fullWidth />
<TextField name="scopes" label="Scopes" defaultValue="openid email profile" fullWidth />
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField name="emailClaim" label="Email claim" defaultValue="email" fullWidth />
</Grid>
<Grid item xs={12} sm={4}>
<TextField name="nameClaim" label="Name claim" defaultValue="name" fullWidth />
</Grid>
<Grid item xs={12} sm={4}>
<TextField name="avatarClaim" label="Avatar claim" defaultValue="picture" fullWidth />
</Grid>
</Grid>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" size="large">
Save OAuth configuration
</Button>
</Box>
</Stack>
</Stack>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -2,127 +2,12 @@ import { redirect } from "next/navigation";
import { getOAuthSettings } from "@/src/lib/settings";
import { getUserCount } from "@/src/lib/models/user";
import { initialOAuthSetupAction } from "./actions";
import OAuthSetupClient from "./SetupClient";
export default function OAuthSetupPage() {
if (getUserCount() > 0 && getOAuthSettings()) {
redirect("/login");
}
return (
<div className="page">
<div className="panel">
<h1>Configure OAuth2</h1>
<p>
Provide the OAuth configuration for your identity provider to finish setting up Caddy Proxy Manager. The first user who signs in
becomes the administrator.
</p>
<form action={initialOAuthSetupAction} className="form">
<label>
Authorization URL
<input name="authorizationUrl" placeholder="https://id.example.com/oauth2/authorize" required />
</label>
<label>
Token URL
<input name="tokenUrl" placeholder="https://id.example.com/oauth2/token" required />
</label>
<label>
User info URL
<input name="userInfoUrl" placeholder="https://id.example.com/oauth2/userinfo" required />
</label>
<label>
Client ID
<input name="clientId" placeholder="client-id" required />
</label>
<label>
Client secret
<input name="clientSecret" placeholder="client-secret" required />
</label>
<label>
Scopes
<input name="scopes" defaultValue="openid email profile" />
</label>
<div className="stack">
<label>
Email claim
<input name="emailClaim" defaultValue="email" />
</label>
<label>
Name claim
<input name="nameClaim" defaultValue="name" />
</label>
<label>
Avatar claim
<input name="avatarClaim" defaultValue="picture" />
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save OAuth configuration
</button>
</div>
</form>
</div>
<style jsx>{`
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(0, 114, 255, 0.2), rgba(3, 8, 18, 0.95));
}
.panel {
width: min(640px, 90vw);
background: rgba(8, 12, 20, 0.95);
padding: 2.5rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 {
margin: 0;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.7);
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(3, 8, 18, 0.92);
color: #fff;
}
.stack {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.75rem 1.6rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
font-weight: 600;
}
`}</style>
</div>
);
return <OAuthSetupClient startSetup={initialOAuthSetupAction} />;
}

3
next-env.d.ts vendored
View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

1887
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,18 +11,22 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.6.0",
"next": "^15.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"next": "^16.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"eslint": "^8.57.0",
"eslint-config-next": "^15.0.0",
"typescript": "^5.4.5"
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"eslint": "^9.38.0",
"eslint-config-next": "^16.0.1",
"typescript": "^5.9.3"
}
}

View File

@@ -7,6 +7,16 @@ import { config } from "../config";
const SESSION_COOKIE = "cpm_session";
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
type CookiesHandle = Awaited<ReturnType<typeof cookies>>;
async function getCookieStore(): Promise<CookiesHandle> {
const store = cookies();
if (typeof (store as any)?.then === "function") {
return (await store) as CookiesHandle;
}
return store as CookiesHandle;
}
function hashToken(token: string): string {
return crypto.createHmac("sha256", config.sessionSecret).update(token).digest("hex");
}
@@ -37,7 +47,7 @@ export type SessionContext = {
user: UserRecord;
};
export function createSession(userId: number): SessionRecord {
export async function createSession(userId: number): Promise<SessionRecord> {
const token = crypto.randomBytes(48).toString("base64url");
const hashed = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
@@ -56,24 +66,30 @@ export function createSession(userId: number): SessionRecord {
created_at: nowIso()
};
const cookieStore = cookies();
cookieStore.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: new Date(expiresAt)
});
const cookieStore = await getCookieStore();
if (typeof (cookieStore as any).set === "function") {
(cookieStore as any).set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: new Date(expiresAt)
});
} else {
console.warn("Unable to set session cookie in this context.");
}
return session;
}
export function destroySession() {
const cookieStore = cookies();
const token = cookieStore.get(SESSION_COOKIE);
cookieStore.delete(SESSION_COOKIE);
export async function destroySession() {
const cookieStore = await getCookieStore();
const token = typeof (cookieStore as any).get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
if (!token) {
return;
@@ -83,9 +99,9 @@ export function destroySession() {
db.prepare("DELETE FROM sessions WHERE token = ?").run(hashed);
}
export function getSession(): SessionContext | null {
const cookieStore = cookies();
const token = cookieStore.get(SESSION_COOKIE);
export async function getSession(): Promise<SessionContext | null> {
const cookieStore = await getCookieStore();
const token = typeof (cookieStore as any).get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (!token) {
return null;
}
@@ -100,13 +116,17 @@ export function getSession(): SessionContext | null {
.get(hashed) as SessionRecord | undefined;
if (!session) {
cookieStore.delete(SESSION_COOKIE);
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}
if (new Date(session.expires_at).getTime() < Date.now()) {
db.prepare("DELETE FROM sessions WHERE id = ?").run(session.id);
cookieStore.delete(SESSION_COOKIE);
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}
@@ -118,15 +138,17 @@ export function getSession(): SessionContext | null {
.get(session.user_id) as UserRecord | undefined;
if (!user || user.status !== "active") {
cookieStore.delete(SESSION_COOKIE);
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}
return { session, user };
}
export function requireUser(): SessionContext {
const context = getSession();
export async function requireUser(): Promise<SessionContext> {
const context = await getSession();
if (!context) {
redirect("/login");
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
@@ -11,14 +15,34 @@
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"types": ["node"],
"types": [
"node"
],
"paths": {
"@/*": ["./*"],
"@/src/*": ["src/*"]
}
"@/*": [
"./*"
],
"@/src/*": [
"src/*"
]
},
"esModuleInterop": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}