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:
38
app/(auth)/login/LoginClient.tsx
Normal file
38
app/(auth)/login/LoginClient.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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} />;
|
||||
}
|
||||
|
||||
86
app/(dashboard)/DashboardLayoutClient.tsx
Normal file
86
app/(dashboard)/DashboardLayoutClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
app/(dashboard)/OverviewClient.tsx
Normal file
85
app/(dashboard)/OverviewClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
app/(dashboard)/access-lists/AccessListsClient.tsx
Normal file
140
app/(dashboard)/access-lists/AccessListsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
41
app/(dashboard)/audit-log/AuditLogClient.tsx
Normal file
41
app/(dashboard)/audit-log/AuditLogClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
169
app/(dashboard)/certificates/CertificatesClient.tsx
Normal file
169
app/(dashboard)/certificates/CertificatesClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
155
app/(dashboard)/dead-hosts/DeadHostsClient.tsx
Normal file
155
app/(dashboard)/dead-hosts/DeadHostsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
217
app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
Normal file
217
app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
157
app/(dashboard)/redirects/RedirectsClient.tsx
Normal file
157
app/(dashboard)/redirects/RedirectsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
108
app/(dashboard)/settings/SettingsClient.tsx
Normal file
108
app/(dashboard)/settings/SettingsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
134
app/(dashboard)/streams/StreamsClient.tsx
Normal file
134
app/(dashboard)/streams/StreamsClient.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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'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} />;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
52
app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
app/setup/oauth/SetupClient.tsx
Normal file
62
app/setup/oauth/SetupClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
3
next-env.d.ts
vendored
@@ -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
1887
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user