diff --git a/app/(dashboard)/OverviewClient.tsx b/app/(dashboard)/OverviewClient.tsx
index 1f0993fc..9b20bee0 100644
--- a/app/(dashboard)/OverviewClient.tsx
+++ b/app/(dashboard)/OverviewClient.tsx
@@ -1,9 +1,8 @@
"use client";
import Link from "next/link";
-import Grid from "@mui/material/Grid";
-import { Card, CardActionArea, CardContent, Paper, Stack, Typography, Box } from "@mui/material";
-import BarChartIcon from "@mui/icons-material/BarChart";
+import { Card, CardContent } from "@/components/ui/card";
+import { BarChart2 } from "lucide-react";
import { ReactNode } from "react";
type StatCard = {
@@ -35,155 +34,111 @@ export default function OverviewClient({
recentEvents: RecentEvent[];
}) {
return (
-
-
-
+
+
+
Control Center
-
-
+
Welcome back, {userName}
-
-
+
+
Everything you need to orchestrate Caddy proxies, certificates, and secure edge services lives here.
-
-
+
+
-
+
{stats.map((stat) => (
-
-
+
-
-
-
- {stat.icon}
-
-
- {stat.count}
-
-
- {stat.label}
-
-
-
-
-
+
+
+ {stat.icon}
+
+
+ {stat.count}
+
+
+ {stat.label}
+
+
+
+
))}
{/* Traffic (24h) card */}
-
-
-
-
-
-
-
- {trafficSummary ? (
- <>
-
- {trafficSummary.totalRequests.toLocaleString()}
-
-
- Traffic (24h)
- {trafficSummary.totalRequests > 0 && (
- 0 ? "error.light" : "text.secondary", fontSize: "0.8em" }}>
- · {trafficSummary.blockedPercent}% blocked
-
- )}
-
- >
- ) : (
- <>
- —
- Traffic (24h)
- >
- )}
-
-
-
-
-
-
-
-
- Recent Activity
-
- {recentEvents.length === 0 ? (
-
+
+
+
+
+
+ {trafficSummary ? (
+ <>
+
+ {trafficSummary.totalRequests.toLocaleString()}
+
+
+ Traffic (24h)
+ {trafficSummary.totalRequests > 0 && (
+ 0 ? "text-red-400" : "text-muted-foreground"}`}
+ >
+ · {trafficSummary.blockedPercent}% blocked
+
+ )}
+
+ >
+ ) : (
+ <>
+ —
+ Traffic (24h)
+ >
+ )}
+
+
+
+
+
+
+
Recent Activity
+ {recentEvents.length === 0 ? (
+
No activity recorded yet.
-
+
) : (
-
+
{recentEvents.map((event, index) => (
-
- {event.summary}
-
+ {event.summary}
+
{new Date(event.created_at).toLocaleString()}
-
-
+
+
))}
-
+
)}
-
-
+
+
);
}
diff --git a/app/(dashboard)/access-lists/AccessListsClient.tsx b/app/(dashboard)/access-lists/AccessListsClient.tsx
index 82babb69..1c848f2c 100644
--- a/app/(dashboard)/access-lists/AccessListsClient.tsx
+++ b/app/(dashboard)/access-lists/AccessListsClient.tsx
@@ -1,23 +1,12 @@
"use client";
-import {
- Box,
- Button,
- Card,
- CardContent,
- Divider,
- IconButton,
- List,
- ListItem,
- ListItemSecondaryAction,
- ListItemText,
- Pagination,
- Stack,
- TextField,
- Typography
-} from "@mui/material";
-import DeleteIcon from "@mui/icons-material/Delete";
-import type { AccessList } from "@/src/lib/models/access-lists";
+import { Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import type { AccessList } from "@/lib/models/access-lists";
import {
addAccessEntryAction,
createAccessListAction,
@@ -38,128 +27,157 @@ export default function AccessListsClient({ lists, pagination }: Props) {
const searchParams = useSearchParams();
const pageCount = Math.ceil(pagination.total / pagination.perPage);
- function handlePageChange(_: React.ChangeEvent, page: number) {
+ function handlePageChange(page: number) {
const params = new URLSearchParams(searchParams.toString());
params.set("page", String(page));
router.push(`${pathname}?${params.toString()}`);
}
- return (
-
-
-
- Access Lists
-
- Protect proxy hosts with HTTP basic authentication credentials.
-
-
+ return (
+
+
+
Access Lists
+
Protect proxy hosts with HTTP basic authentication credentials.
+
+
+
{lists.map((list) => (
-
- updateAccessListAction(list.id, formData)} spacing={2}>
-
- Access List
-
-
-
-
-
+
+
-
+
-
- Accounts
+
+
Accounts
{list.entries.length === 0 ? (
-
No credentials configured.
+
No credentials configured.
) : (
-
+
{list.entries.map((entry) => (
-
-
-
-
-
-
-
-
-
-
+
+
+
{entry.username}
+
+ Created {new Date(entry.created_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
))}
-
+
)}
-
+
-
+
- addAccessEntryAction(list.id, formData)} spacing={1.5} direction={{ xs: "column", sm: "row" }}>
-
-
-
- Add
-
-
+ addAccessEntryAction(list.id, formData)}
+ className="flex flex-col sm:flex-row gap-2 items-end"
+ >
+
+ Username
+
+
+
+ Password
+
+
+ Add
+
))}
-
+
{pageCount > 1 && (
-
-
-
+
+ {Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
+ handlePageChange(page)}
+ >
+ {page}
+
+ ))}
+
)}
-
-
- Create access list
-
+
+ Create access list
-
-
-
-
-
-
-
- Create Access List
-
-
-
+
+
+
+ Name
+
+
+
+ Description
+
+
+
+
Seed members
+
+
One per line, username:password
+
+
+ Create Access List
+
+
-
-
+
+
);
}
diff --git a/app/(dashboard)/audit-log/AuditLogClient.tsx b/app/(dashboard)/audit-log/AuditLogClient.tsx
index f868a57b..2005dd3c 100644
--- a/app/(dashboard)/audit-log/AuditLogClient.tsx
+++ b/app/(dashboard)/audit-log/AuditLogClient.tsx
@@ -2,9 +2,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { Card, Chip, Stack, TextField, Typography } from "@mui/material";
-import SearchIcon from "@mui/icons-material/Search";
-import { DataTable } from "@/src/components/ui/DataTable";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { DataTable } from "@/components/ui/DataTable";
+import { SearchField } from "@/components/ui/SearchField";
type EventRow = {
id: number;
@@ -58,9 +59,9 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
label: "Time",
width: 180,
render: (r: EventRow) => (
-
+
{new Date(r.created_at).toLocaleString()}
-
+
),
},
{
@@ -68,53 +69,44 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
label: "User",
width: 160,
render: (r: EventRow) => (
-
+ {r.user}
),
},
{
id: "summary",
label: "Event",
render: (r: EventRow) => (
- {r.summary}
+ {r.summary}
),
},
];
const mobileCard = (r: EventRow) => (
-
-
-
-
-
+
+
+
+ {r.user}
+
{new Date(r.created_at).toLocaleString()}
-
-
- {r.summary}
-
+
+
+ {r.summary}
+
);
return (
-
-
- Audit Log
-
- Review configuration changes and user activity.
+
+
Audit Log
+
Review configuration changes and user activity.
-
{
setSearchTerm(e.target.value);
updateSearch(e.target.value);
}}
- slotProps={{
- input: {
- startAdornment: ,
- },
- }}
- size="small"
- sx={{ maxWidth: 400 }}
+ placeholder="Search audit log..."
/>
-
+
);
}
diff --git a/app/(dashboard)/certificates/CertificatesClient.tsx b/app/(dashboard)/certificates/CertificatesClient.tsx
index 6b37e669..08d25272 100644
--- a/app/(dashboard)/certificates/CertificatesClient.tsx
+++ b/app/(dashboard)/certificates/CertificatesClient.tsx
@@ -1,7 +1,8 @@
"use client";
-import { Box, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
import { StatusSummaryBar } from "./components/StatusSummaryBar";
import { AcmeTab } from "./components/AcmeTab";
@@ -54,23 +55,21 @@ export default function CertificatesClient({
const setSearch =
activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : setSearchCa;
- function handleTabChange(_: React.SyntheticEvent, value: TabId) {
- setActiveTab(value);
+ function handleTabChange(value: string) {
+ setActiveTab(value as TabId);
setStatusFilter(null);
}
return (
-
+
{/* Page header */}
-
-
- SSL/TLS Certificates
-
-
+
+
SSL/TLS Certificates
+
Caddy automatically handles HTTPS certificates for all proxy hosts using Let's Encrypt.
Import custom certificates only when needed (internal CA, special requirements, etc.).
-
-
+
+
{/* Status summary bar */}
- {/* Tabs */}
-
-
-
-
-
-
-
-
{/* Per-tab search */}
- setSearch(e.target.value)}
- size="small"
- sx={{ maxWidth: 400 }}
- inputProps={{ "aria-label": "search" }}
+ className="max-w-sm"
+ aria-label="search"
/>
- {/* Tab panels */}
- {activeTab === "acme" && (
-
- )}
- {activeTab === "imported" && (
-
- )}
- {activeTab === "ca" && (
-
- )}
-
+ {/* Tabs */}
+
+
+ ACME ({acmePagination.total})
+ Imported ({importedCerts.length})
+ CA / mTLS ({caCertificates.length})
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index e1578ca4..3ebe0930 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -8,9 +8,7 @@ import {
proxyHosts
} from "@/src/lib/db/schema";
import { count, desc, isNull, sql } from "drizzle-orm";
-import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
-import SecurityIcon from "@mui/icons-material/Security";
-import VpnKeyIcon from "@mui/icons-material/VpnKey";
+import { ArrowLeftRight, ShieldCheck, KeyRound } from "lucide-react";
import { ReactNode } from "react";
import { getAnalyticsSummary } from "@/src/lib/analytics-db";
@@ -38,9 +36,9 @@ async function loadStats(): Promise {
const accessListsCount = accessListCountResult[0]?.value ?? 0;
return [
- { label: "Proxy Hosts", icon: , count: proxyHostsCount, href: "/proxy-hosts" },
- { label: "Certificates", icon: , count: certificatesCount, href: "/certificates" },
- { label: "Access Lists", icon: , count: accessListsCount, href: "/access-lists" }
+ { label: "Proxy Hosts", icon: , count: proxyHostsCount, href: "/proxy-hosts" },
+ { label: "Certificates", icon: , count: certificatesCount, href: "/certificates" },
+ { label: "Access Lists", icon: , count: accessListsCount, href: "/access-lists" }
];
}
diff --git a/app/(dashboard)/profile/ProfileClient.tsx b/app/(dashboard)/profile/ProfileClient.tsx
index 88018aa3..f7a12043 100644
--- a/app/(dashboard)/profile/ProfileClient.tsx
+++ b/app/(dashboard)/profile/ProfileClient.tsx
@@ -1,36 +1,26 @@
"use client";
import { useState } from "react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
import {
- Alert,
- Avatar,
- Box,
- Button,
- Card,
- CardContent,
- Chip,
Dialog,
- DialogActions,
DialogContent,
- DialogContentText,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
DialogTitle,
- Divider,
- IconButton,
- Stack,
- TextField,
- Typography
-} from "@mui/material";
-import type { ChipProps } from "@mui/material";
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
import { signIn } from "next-auth/react";
-import PersonIcon from "@mui/icons-material/Person";
-import LockIcon from "@mui/icons-material/Lock";
-import LinkIcon from "@mui/icons-material/Link";
-import LinkOffIcon from "@mui/icons-material/LinkOff";
-import LoginIcon from "@mui/icons-material/Login";
-import PhotoCamera from "@mui/icons-material/PhotoCamera";
-import DeleteIcon from "@mui/icons-material/Delete";
+import { Camera, Link, LogIn, Lock, Trash2, Unlink, User } from "lucide-react";
-interface User {
+interface UserData {
id: number;
email: string;
name: string | null;
@@ -42,7 +32,7 @@ interface User {
}
interface ProfileClientProps {
- user: User;
+ user: UserData;
enabledProviders: Array<{ id: string; name: string }>;
}
@@ -262,295 +252,277 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP
return provider;
};
- const getProviderColor = (provider: string): ChipProps["color"] => {
- if (provider === "credentials") return "default";
- return "primary";
- };
-
return (
-
-
- Profile & Account Settings
-
+
+
Profile & Account Settings
{error && (
-
setError(null)}>
- {error}
+
+
+ {error}
+ setError(null)} className="h-auto p-0 text-xs">Dismiss
+
)}
{success && (
- setSuccess(null)}>
- {success}
+
+
+ {success}
+ setSuccess(null)} className="h-auto p-0 text-xs">Dismiss
+
)}
-
+
{/* Account Information */}
-
-
-
-
- Account Information
-
+
+
+
+
Account Information
+
-
+
- {/* Avatar Section */}
-
-
- Profile Picture
-
-
-
+ {/* Avatar Section */}
+
+
Profile Picture
+
+
+
+
{(!avatarUrl && user.name) ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
-
-
- }
- disabled={loading}
- >
+
+
+
+
+
+
Upload
+
+
+ {avatarUrl && (
+
+
- {avatarUrl && (
-
-
-
- )}
-
-
-
- Recommended: Square image, max 2MB
-
-
+ )}
+
+
+
Recommended: Square image, max 2MB
+
-
+
-
-
- Email
-
- {user.email}
-
+
-
-
- Name
-
- {user.name || "Not set"}
-
+
+
Name
+
{user.name || "Not set"}
+
-
-
- Role
-
-
-
+
-
-
- Authentication Method
-
-
-
+
+
Authentication Method
+
+ {getProviderName(user.provider)}
+
+
- {hasPassword && (
-
-
- Password
-
-
- ✓ Password is set
-
-
- )}
-
+ {hasPassword && (
+
+
Password
+
✓ Password is set
+
+ )}
{/* Password Management */}
-
-
-
-
- Password Management
-
+
+
+
+
Password Management
+
-
+
- {hasPassword ? (
-
-
- Change your password to maintain account security
-
- setPasswordDialogOpen(true)}
- sx={{ mt: 1 }}
- >
- Change Password
-
-
- ) : (
-
-
+ {hasPassword ? (
+
+
Change your password to maintain account security
+
setPasswordDialogOpen(true)}>
+ Change Password
+
+
+ ) : (
+
+
+
You are using OAuth-only authentication. Setting a password will allow you to
sign in with either OAuth or credentials.
-
-
setPasswordDialogOpen(true)}
- >
- Set Password
-
-
- )}
-
+
+
+
setPasswordDialogOpen(true)}>
+ Set Password
+
+
+ )}
{/* OAuth Management */}
{enabledProviders.length > 0 && (
-
-
-
-
- OAuth Connections
-
+
+
+
+
OAuth Connections
+
-
+
- {hasOAuth ? (
-
-
- Your account is linked to {getProviderName(user.provider)}
-
+ {hasOAuth ? (
+
+
+ Your account is linked to {getProviderName(user.provider)}
+
- {hasPassword ? (
+ {hasPassword ? (
+
setUnlinkDialogOpen(true)}
+ >
+
+ Unlink OAuth Account
+
+ ) : (
+
+
+ To unlink OAuth, you must first set a password as a fallback authentication method.
+
+
+ )}
+
+ ) : (
+
+
+ Link an OAuth provider to enable single sign-on
+
+
+
+ {enabledProviders.map((provider) => (
}
- onClick={() => setUnlinkDialogOpen(true)}
- sx={{ mt: 1 }}
+ key={provider.id}
+ variant="outline"
+ onClick={() => handleLinkOAuth(provider.id)}
+ className="w-full"
>
- Unlink OAuth Account
+
+ Link {provider.name}
- ) : (
-
- To unlink OAuth, you must first set a password as a fallback authentication
- method.
-
- )}
-
- ) : (
-
-
- Link an OAuth provider to enable single sign-on
-
-
-
- {enabledProviders.map((provider) => (
- }
- onClick={() => handleLinkOAuth(provider.id)}
- fullWidth
- >
- Link {provider.name}
-
- ))}
-
-
- )}
-
+ ))}
+
+
+ )}
)}
-
+
{/* Change Password Dialog */}
- setPasswordDialogOpen(false)} maxWidth="sm" fullWidth>
- {hasPassword ? "Change Password" : "Set Password"}
-
-
+
+
+
+ {hasPassword ? "Change Password" : "Set Password"}
+
+
+
+ setPasswordDialogOpen(false)}>Cancel
+
+ {loading ? "Saving..." : hasPassword ? "Change Password" : "Set Password"}
+
+
-
- setPasswordDialogOpen(false)}>Cancel
-
- {loading ? "Saving..." : hasPassword ? "Change Password" : "Set Password"}
-
-
{/* Unlink OAuth Dialog */}
- setUnlinkDialogOpen(false)} maxWidth="sm" fullWidth>
- Unlink OAuth Account
-
-
- Are you sure you want to unlink your {getProviderName(user.provider)} account?
- You will only be able to sign in with your username and password after this.
-
+
+
+
+ Unlink OAuth Account
+
+ Are you sure you want to unlink your {getProviderName(user.provider)} account?
+ You will only be able to sign in with your username and password after this.
+
+
+
+ setUnlinkDialogOpen(false)}>Cancel
+
+ {loading ? "Unlinking..." : "Unlink OAuth"}
+
+
-
- setUnlinkDialogOpen(false)}>Cancel
-
- {loading ? "Unlinking..." : "Unlink OAuth"}
-
-
-
+
);
}
diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
index a09b6f9a..2e07d03c 100644
--- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
+++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx
@@ -2,20 +2,21 @@
import { useEffect, useRef, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { Card, IconButton, Stack, Switch, Tooltip, Typography } from "@mui/material";
-import EditIcon from "@mui/icons-material/Edit";
-import DeleteIcon from "@mui/icons-material/Delete";
-import ContentCopyIcon from "@mui/icons-material/ContentCopy";
-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 type { CaCertificate } from "@/src/lib/models/ca-certificates";
-import type { AuthentikSettings } from "@/src/lib/settings";
+import { Copy, Pencil, Trash2 } from "lucide-react";
+import type { AccessList } from "@/lib/models/access-lists";
+import type { Certificate } from "@/lib/models/certificates";
+import type { ProxyHost } from "@/lib/models/proxy-hosts";
+import type { CaCertificate } from "@/lib/models/ca-certificates";
+import type { AuthentikSettings } from "@/lib/settings";
import { toggleProxyHostAction } from "./actions";
-import { PageHeader } from "@/src/components/ui/PageHeader";
-import { SearchField } from "@/src/components/ui/SearchField";
-import { DataTable } from "@/src/components/ui/DataTable";
-import { CreateHostDialog, EditHostDialog, DeleteHostDialog } from "@/src/components/proxy-hosts/HostDialogs";
+import { PageHeader } from "@/components/ui/PageHeader";
+import { SearchField } from "@/components/ui/SearchField";
+import { DataTable } from "@/components/ui/DataTable";
+import { CreateHostDialog, EditHostDialog, DeleteHostDialog } from "@/components/proxy-hosts/HostDialogs";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { Card } from "@/components/ui/card";
type Props = {
hosts: ProxyHost[];
@@ -67,33 +68,27 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
id: "name",
label: "Name",
render: (host: ProxyHost) => (
-
-
- {host.name}
-
-
+ {host.name}
)
},
{
id: "domains",
label: "Domains",
render: (host: ProxyHost) => (
-
-
- {host.domains[0]}
- {host.domains.length > 1 && ` +${host.domains.length - 1} more`}
-
-
+
+ {host.domains[0]}
+ {host.domains.length > 1 && ` +${host.domains.length - 1} more`}
+
)
},
{
id: "upstreams",
label: "Target",
render: (host: ProxyHost) => (
-
+
{host.upstreams[0]}
{host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
-
+
)
},
{
@@ -102,80 +97,120 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
align: "right" as const,
width: 150,
render: (host: ProxyHost) => (
-
+
handleToggleEnabled(host.id, e.target.checked)}
- size="small"
- color="success"
+ onCheckedChange={(checked) => handleToggleEnabled(host.id, checked)}
/>
-
- {
- setDuplicateHost(host);
- setCreateOpen(true);
- }}
- color="info"
- >
-
-
+
+
+ {
+ setDuplicateHost(host);
+ setCreateOpen(true);
+ }}
+ >
+
+
+
+ Duplicate
-
- setEditHost(host)} color="primary">
-
-
+
+
+ setEditHost(host)}
+ >
+
+
+
+ Edit
-
- setDeleteHost(host)} color="error">
-
-
+
+
+ setDeleteHost(host)}
+ >
+
+
+
+ Delete
-
+
)
}
];
const mobileCard = (host: ProxyHost) => (
-
-
-
-
- {host.name}
-
-
+
+
+
+
{host.name}
+
handleToggleEnabled(host.id, e.target.checked)}
- size="small"
- color="success"
+ onCheckedChange={(checked) => handleToggleEnabled(host.id, checked)}
/>
-
- { setDuplicateHost(host); setCreateOpen(true); }} color="info">
-
-
+
+
+ { setDuplicateHost(host); setCreateOpen(true); }}
+ >
+
+
+
+ Duplicate
-
- setEditHost(host)} color="primary">
-
-
+
+
+ setEditHost(host)}
+ >
+
+
+
+ Edit
-
- setDeleteHost(host)} color="error">
-
-
+
+
+ setDeleteHost(host)}
+ >
+
+
+
+ Delete
-
-
-
+
+
+
{host.domains[0]}{host.domains.length > 1 ? ` +${host.domains.length - 1}` : ""} → {host.upstreams[0]}{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
-
-
+
+
);
return (
-
+
setDeleteHost(null)}
/>
)}
-
+
);
}
diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx
index 4797240a..179fec18 100644
--- a/app/(dashboard)/settings/SettingsClient.tsx
+++ b/app/(dashboard)/settings/SettingsClient.tsx
@@ -2,7 +2,13 @@
import { useState } from "react";
import { useFormState } from "react-dom";
-import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type {
GeneralSettings,
AuthentikSettings,
@@ -11,8 +17,8 @@ import type {
DnsSettings,
UpstreamDnsResolutionSettings,
GeoBlockSettings,
-} from "@/src/lib/settings";
-import { GeoBlockFields } from "@/src/components/proxy-hosts/GeoBlockFields";
+} from "@/lib/settings";
+import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
import {
updateCloudflareSettingsAction,
updateGeneralSettingsAction,
@@ -30,6 +36,33 @@ import {
updateGeoBlockSettingsAction,
} from "./actions";
+// Helper to render a status alert with appropriate color
+function StatusAlert({ message, success }: { message: string; success: boolean }) {
+ return (
+
+ {message}
+
+ );
+}
+
+// Info alert
+function InfoAlert({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Warning alert
+function WarnAlert({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
type Props = {
general: GeneralSettings | null;
cloudflare: {
@@ -118,666 +151,650 @@ export default function SettingsClient({
);
return (
-
-
-
- Settings
-
- Configure organization-wide defaults and DNS automation.
-
+
+
+
Settings
+
Configure organization-wide defaults and DNS automation.
+
+ {/* Instance Sync */}
-
-
- Instance Sync
-
-
+
+ Instance Sync
+
Choose whether this instance acts independently, pushes configuration to slave nodes, or pulls configuration from a master.
-
-
+
+
{instanceSync.modeFromEnv && (
-
+
Instance mode is configured via INSTANCE_MODE environment variable and cannot be changed at runtime.
-
+
)}
{instanceModeState?.message && (
-
- {instanceModeState.message}
-
+
)}
-
- Standalone
- Master
- Slave
-
-
-
+
+ Instance mode
+
+
+
+
+
+ Standalone
+ Master
+ Slave
+
+
+
+
+
Save instance mode
-
-
+
+
{isSlave && (
-
-
- Master Connection
-
-
+
+
Master Connection
+
{instanceSync.tokenFromEnv && (
-
+
Sync token is configured via INSTANCE_SYNC_TOKEN environment variable and cannot be changed at runtime.
-
+
)}
{slaveTokenState?.message && (
-
- {slaveTokenState.message}
-
+
)}
{instanceSync.slave?.hasToken && !instanceSync.tokenFromEnv && (
-
- A master sync token is configured. Leave the token field blank to keep it, or select "Remove existing token" to delete it.
-
+
+ A master sync token is configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it.
+
)}
-
- }
- label="Remove existing token"
- disabled={!instanceSync.slave?.hasToken || instanceSync.tokenFromEnv}
- />
-
-
+
+ Master sync token
+
+
+
+
+ Remove existing token
+
+
+
Save master token
-
-
-
- {instanceSync.slave?.lastSyncAt
- ? `Last sync: ${instanceSync.slave.lastSyncAt}${instanceSync.slave.lastSyncError ? ` (${instanceSync.slave.lastSyncError})` : ""}`
- : "No sync payload has been received yet."}
-
-
+
+
+ {instanceSync.slave?.lastSyncError ? (
+
+ {instanceSync.slave?.lastSyncAt
+ ? `Last sync: ${instanceSync.slave.lastSyncAt} (${instanceSync.slave.lastSyncError})`
+ : "No sync payload has been received yet."}
+
+ ) : (
+
+ {instanceSync.slave?.lastSyncAt
+ ? `Last sync: ${instanceSync.slave.lastSyncAt}`
+ : "No sync payload has been received yet."}
+
+ )}
+
)}
{isMaster && (
-
-
- Slave Instances
-
-
+
+
Slave Instances
+
{slaveInstanceState?.message && (
-
- {slaveInstanceState.message}
-
+
)}
-
-
-
-
-
- Add slave instance
-
-
-
+
+ Instance name
+
+
+
+ Base URL
+
+
+
+ Slave API token
+
+
+
+ Add slave instance
+
+
-
+
{syncState?.message && (
-
- {syncState.message}
-
+
)}
-
-
- Sync now
-
-
-
+
+ Sync now
+
+
{instanceSync.master?.instances.length === 0 && instanceSync.master?.envInstances.length === 0 && (
-
No slave instances configured yet.
+
No slave instances configured yet.
)}
{instanceSync.master?.envInstances && instanceSync.master.envInstances.length > 0 && (
<>
-
+
Environment-configured instances (via INSTANCE_SLAVES)
-
+
{instanceSync.master.envInstances.map((instance, index) => (
-
-
- {instance.name}
-
- {instance.url}
-
-
- Configured via environment variable
-
-
-
+
+
{instance.name}
+
{instance.url}
+
Configured via environment variable
+
+
))}
>
)}
{instanceSync.master?.instances && instanceSync.master.instances.length > 0 && (
-
- UI-configured instances
-
+ UI-configured instances
)}
{instanceSync.master?.instances.map((instance) => (
-
-
- {instance.name}
-
- {instance.base_url}
-
-
+
+
{instance.name}
+
{instance.base_url}
+
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
-
+
{instance.last_sync_error && (
-
- {instance.last_sync_error}
-
+
{instance.last_sync_error}
)}
-
-
-
+
+
+
-
+
{instance.enabled ? "Disable" : "Enable"}
-
-
+
+
-
+
Remove
-
-
-
+
+
+
))}
-
+
)}
+ {/* General */}
-
-
- General
-
-
+
+ General
+
{generalState?.message && (
-
- {generalState.message}
-
+
)}
{isSlave && (
- setGeneralOverride(event.target.checked)}
- />
- }
- label="Override master settings"
- />
+
+ setGeneralOverride(!!v)}
+ />
+ Override master settings
+
)}
-
-
-
-
- Save general settings
-
-
-
+
+ Primary domain
+
+
+
+ ACME contact email
+
+
+
+ Save general settings
+
+
+ {/* Cloudflare DNS */}
-
-
- Cloudflare DNS
-
-
+
+ Cloudflare DNS
+
Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates.
-
+
{cloudflare.hasToken && (
-
- A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it.
-
+
+ A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it.
+
)}
-
+
{cloudflareState?.message && (
-
- {cloudflareState.message}
-
+
)}
{isSlave && (
- setCloudflareOverride(event.target.checked)}
- />
- }
- label="Override master settings"
- />
+
+ setCloudflareOverride(!!v)}
+ />
+ Override master settings
+
)}
-
- }
- label="Remove existing token"
- disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)}
- />
-
-
-
-
- Save Cloudflare settings
-
-
-
+
+ API token
+
+
+
+
+ Remove existing token
+
+
+ Zone ID
+
+
+
+ Account ID
+
+
+
+ Save Cloudflare settings
+
+
+ {/* DNS Resolvers */}
-
-
- DNS Resolvers
-
-
+
+ DNS Resolvers
+
Configure custom DNS resolvers for ACME DNS-01 challenges. These resolvers will be used to verify DNS records during certificate issuance.
-
-
+
+
{dnsState?.message && (
-
- {dnsState.message}
-
+
)}
{isSlave && (
- setDnsOverride(event.target.checked)}
- />
- }
- label="Override master settings"
- />
+
+ setDnsOverride(!!v)}
+ />
+ Override master settings
+
)}
- }
- label="Enable custom DNS resolvers"
- />
-
-
-
-
+
+
+ Enable custom DNS resolvers
+
+
+
Primary DNS Resolvers
+
+
One resolver per line (e.g., 1.1.1.1, 8.8.8.8). Used for ACME DNS verification.
+
+
+
Fallback DNS Resolvers (Optional)
+
+
Fallback resolvers if primary fails. One per line.
+
+
+
DNS Query Timeout
+
+
Timeout for DNS queries (e.g., 5s, 10s)
+
+
Custom DNS resolvers are useful when your DNS provider has slow propagation or when using split-horizon DNS.
Common public resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
-
-
-
- Save DNS settings
-
-
-
+
+
+ Save DNS settings
+
+
+ {/* Upstream DNS Pinning */}
-
-
- Upstream DNS Pinning
-
-
+
+ Upstream DNS Pinning
+
Optionally resolve upstream hostnames when applying config and pin reverse proxy upstream dials to IP addresses.
This can avoid runtime DNS churn and lets you force IPv6, IPv4, or both (IPv6 preferred).
-
-
+
+
{upstreamDnsResolutionState?.message && (
-
- {upstreamDnsResolutionState.message}
-
+
)}
{isSlave && (
- setUpstreamDnsResolutionOverride(event.target.checked)}
- />
- }
- label="Override master settings"
- />
+
+ setUpstreamDnsResolutionOverride(!!v)}
+ />
+ Override master settings
+
)}
- }
- label="Enable upstream DNS pinning during config apply"
- />
-
- Both (Prefer IPv6)
- IPv6 only
- IPv4 only
-
-
+
+
+ Enable upstream DNS pinning during config apply
+
+
+
Address Family Preference
+
+
+
+
+
+ Both (Prefer IPv6)
+ IPv6 only
+ IPv4 only
+
+
+
Both resolves AAAA + A with IPv6 preferred ordering.
+
+
Host-level settings can override this default. Resolution happens at config save/reload time and resolved IPs are written into
- Caddy's active config. If one handler has multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those
+ Caddy's active config. If one handler has multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those
HTTPS upstreams to avoid SNI mismatch.
-
-
-
- Save upstream DNS pinning settings
-
-
-
+
+
+ Save upstream DNS pinning settings
+
+
+ {/* Authentik Defaults */}
-
-
- Authentik Defaults
-
-
+
+ Authentik Defaults
+
Set default Authentik forward authentication values. These will be pre-filled when creating new proxy hosts but can be customized per host.
-
-
+
+
{authentikState?.message && (
-
- {authentikState.message}
-
+
)}
{isSlave && (
- setAuthentikOverride(event.target.checked)}
- />
- }
- label="Override master settings"
- />
+
+ setAuthentikOverride(!!v)}
+ />
+ Override master settings
+
)}
-
-
-
-
-
- Save Authentik defaults
-
-
-
+
+
Outpost Domain
+
+
Authentik outpost domain
+
+
+
Outpost Upstream
+
+
Internal URL of Authentik outpost
+
+
+
Authpost Endpoint
+
+
Authpost endpoint path
+
+
+ Save Authentik defaults
+
+
+ {/* Metrics & Monitoring */}
-
-
- Metrics & Monitoring
-
-
+
+ Metrics & Monitoring
+
Enable Caddy metrics exposure for monitoring with Prometheus, Grafana, or other observability tools.
Metrics will be available at http://caddy:{metrics?.port ?? 9090}/metrics on a separate port (NOT the admin API port for security).
-
-
+
+
{metricsState?.message && (
-
- {metricsState.message}
-
+
)}
{isSlave && (
- setMetricsOverride(event.target.checked)}
- />
- }
- label="Override master settings"
- />
+
+ setMetricsOverride(!!v)}
+ />
+ Override master settings
+
)}
- }
- label="Enable metrics endpoint"
- />
-
-
+
+
+ Enable metrics endpoint
+
+
+
Metrics Port
+
+
Port to expose metrics on (default: 9090, separate from admin API on 2019)
+
+
After enabling metrics, configure your monitoring tool to scrape http://caddy-proxy-manager-caddy:{metrics?.port ?? 9090}/metrics from within the Docker network.
- To expose metrics externally, add a port mapping like "{metrics?.port ?? 9090}:{metrics?.port ?? 9090}" in docker-compose.yml.
-
-
-
- Save metrics settings
-
-
-
+ To expose metrics externally, add a port mapping like “{metrics?.port ?? 9090}:{metrics?.port ?? 9090}” in docker-compose.yml.
+
+
+ Save metrics settings
+
+
+ {/* Access Logging */}
-
-
- Access Logging
-
-
+
+ Access Logging
+
Enable HTTP access logging to track all requests going through your proxy hosts.
Logs will be stored in the caddy-logs directory and mounted at /logs/access.log inside the container.
-
-
+
+
{loggingState?.message && (
-
- {loggingState.message}
-
+
)}
{isSlave && (
- setLoggingOverride(event.target.checked)}
- />
- }
- label="Override master settings"
- />
+
+ setLoggingOverride(!!v)}
+ />
+ Override master settings
+
)}
- }
- label="Enable access logging"
- />
-
- JSON
- Console (Common Log Format)
-
-
+
+
+ Enable access logging
+
+
+
Log Format
+
+
+
+
+
+ JSON
+ Console (Common Log Format)
+
+
+
Format for access logs
+
+
Access logs are stored in the caddy-logs Docker volume.
You can view them with: docker exec caddy-proxy-manager-caddy tail -f /logs/access.log
-
-
-
- Save logging settings
-
-
-
+
+
+ Save logging settings
+
+
+ {/* Global Geoblocking */}
-
-
- Global Geoblocking
-
-
+
+ Global Geoblocking
+
Configure default geoblocking rules applied to all proxy hosts. Per-host rules can merge with or override these global defaults.
-
-
+
+
{geoBlockState?.message && (
-
- {geoBlockState.message}
-
+
)}
-
-
- Save geoblocking settings
-
-
-
+
+ Save geoblocking settings
+
+
-
-
+
);
}
diff --git a/app/providers.tsx b/app/providers.tsx
index 4f32cb18..657dc3b4 100644
--- a/app/providers.tsx
+++ b/app/providers.tsx
@@ -3,11 +3,14 @@
import { ReactNode } from "react";
import { ThemeProvider } from "next-themes";
import { Toaster } from "sonner";
+import { TooltipProvider } from "@/components/ui/tooltip";
export default function Providers({ children }: { children: ReactNode }) {
return (
- {children}
+
+ {children}
+
);