feat: rewrite overview and core dashboard pages with shadcn

Replaces all MUI components in 8 dashboard page files with shadcn/ui
and Tailwind. Adds global TooltipProvider to app/providers.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-22 15:54:04 +01:00
parent ec97e2a905
commit 896ee2281b
9 changed files with 1161 additions and 1183 deletions

View File

@@ -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 (
<Stack spacing={5}>
<Stack spacing={1.5}>
<Typography variant="overline" sx={{ color: "rgba(148, 163, 184, 0.6)", letterSpacing: 4 }}>
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-400/60">
Control Center
</Typography>
<Typography
variant="h4"
sx={{
fontWeight: 700,
</span>
<h1
className="text-3xl font-bold"
style={{
background: "linear-gradient(120deg, rgba(127, 91, 255, 1) 0%, rgba(34, 211, 238, 0.9) 80%)",
WebkitBackgroundClip: "text",
color: "transparent"
WebkitTextFillColor: "transparent"
}}
>
Welcome back, {userName}
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 560 }}>
</h1>
<p className="text-sm text-muted-foreground max-w-[560px]">
Everything you need to orchestrate Caddy proxies, certificates, and secure edge services lives here.
</Typography>
</Stack>
</p>
</div>
<Grid container spacing={3}>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{stats.map((stat) => (
<Grid key={stat.label} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card
elevation={0}
sx={{
height: "100%",
border: "1px solid rgba(148, 163, 184, 0.14)"
}}
<Card
key={stat.label}
className="border border-slate-400/10 bg-transparent shadow-none h-full"
>
<Link
href={stat.href}
className="block h-full transition-colors hover:bg-gradient-to-br hover:from-violet-500/10 hover:to-cyan-400/[0.06] rounded-[inherit]"
>
<CardActionArea
component={Link}
href={stat.href}
sx={{
height: "100%",
p: 0,
"&:hover": {
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.16), rgba(34, 211, 238, 0.08))"
}
}}
>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box
sx={{
color: "rgba(127, 91, 255, 0.8)",
display: "flex",
alignItems: "center"
}}
>
{stat.icon}
</Box>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}>
{stat.count}
</Typography>
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>
{stat.label}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
<CardContent className="flex flex-col gap-1 pt-6">
<div className="text-violet-400/80 flex items-center">
{stat.icon}
</div>
<span className="text-3xl font-bold tracking-tight">
{stat.count}
</span>
<span className="text-sm text-muted-foreground font-medium">
{stat.label}
</span>
</CardContent>
</Link>
</Card>
))}
{/* Traffic (24h) card */}
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card elevation={0} sx={{ height: "100%", border: "1px solid rgba(148, 163, 184, 0.14)" }}>
<CardActionArea
component={Link}
href="/analytics"
sx={{
height: "100%",
p: 0,
"&:hover": {
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.16), rgba(34, 211, 238, 0.08))"
}
}}
>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box sx={{ color: "rgba(127, 91, 255, 0.8)", display: "flex", alignItems: "center" }}>
<BarChartIcon fontSize="large" />
</Box>
{trafficSummary ? (
<>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}>
{trafficSummary.totalRequests.toLocaleString()}
</Typography>
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>
Traffic (24h)
{trafficSummary.totalRequests > 0 && (
<Box component="span" sx={{ ml: 1, color: trafficSummary.blockedPercent > 0 ? "error.light" : "text.secondary", fontSize: "0.8em" }}>
· {trafficSummary.blockedPercent}% blocked
</Box>
)}
</Typography>
</>
) : (
<>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}></Typography>
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>Traffic (24h)</Typography>
</>
)}
</CardContent>
</CardActionArea>
</Card>
</Grid>
</Grid>
<Stack spacing={2}>
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: -0.2 }}>
Recent Activity
</Typography>
{recentEvents.length === 0 ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: "center",
color: "text.secondary",
background: "rgba(12, 18, 30, 0.7)"
}}
<Card className="border border-slate-400/10 bg-transparent shadow-none h-full">
<Link
href="/analytics"
className="block h-full transition-colors hover:bg-gradient-to-br hover:from-violet-500/10 hover:to-cyan-400/[0.06] rounded-[inherit]"
>
<CardContent className="flex flex-col gap-1 pt-6">
<div className="text-violet-400/80 flex items-center">
<BarChart2 className="h-8 w-8" />
</div>
{trafficSummary ? (
<>
<span className="text-3xl font-bold tracking-tight">
{trafficSummary.totalRequests.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground font-medium">
Traffic (24h)
{trafficSummary.totalRequests > 0 && (
<span
className={`ml-1 text-[0.8em] ${trafficSummary.blockedPercent > 0 ? "text-red-400" : "text-muted-foreground"}`}
>
· {trafficSummary.blockedPercent}% blocked
</span>
)}
</span>
</>
) : (
<>
<span className="text-3xl font-bold tracking-tight"></span>
<span className="text-sm text-muted-foreground font-medium">Traffic (24h)</span>
</>
)}
</CardContent>
</Link>
</Card>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold tracking-tight">Recent Activity</h2>
{recentEvents.length === 0 ? (
<div className="p-8 text-center text-muted-foreground rounded-md bg-[rgba(12,18,30,0.7)]">
No activity recorded yet.
</Paper>
</div>
) : (
<Stack spacing={1.5}>
<div className="flex flex-col gap-1.5">
{recentEvents.map((event, index) => (
<Paper
<div
key={`${event.created_at}-${index}`}
elevation={0}
sx={{
p: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 2,
background: "linear-gradient(120deg, rgba(17, 25, 40, 0.9), rgba(15, 23, 42, 0.7))",
border: "1px solid rgba(148, 163, 184, 0.08)"
}}
className="flex justify-between items-center gap-2 rounded-md p-4 border border-slate-400/[0.08]"
style={{ background: "linear-gradient(120deg, rgba(17, 25, 40, 0.9), rgba(15, 23, 42, 0.7))" }}
>
<Typography fontWeight={500}>{event.summary}</Typography>
<Typography variant="body2" color="text.secondary">
<span className="text-sm font-medium">{event.summary}</span>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(event.created_at).toLocaleString()}
</Typography>
</Paper>
</span>
</div>
))}
</Stack>
</div>
)}
</Stack>
</Stack>
</div>
</div>
);
}

View File

@@ -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<unknown>, page: number) {
function handlePageChange(page: number) {
const params = new URLSearchParams(searchParams.toString());
params.set("page", String(page));
router.push(`${pathname}?${params.toString()}`);
}
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}>
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Access Lists</h1>
<p className="text-sm text-muted-foreground">Protect proxy hosts with HTTP basic authentication credentials.</p>
</div>
<div className="flex flex-col gap-4">
{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">
<CardContent className="flex flex-col gap-4 pt-6">
<form action={(formData) => updateAccessListAction(list.id, formData)} className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Access List</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`name-${list.id}`}>Name</Label>
<Input id={`name-${list.id}`} name="name" defaultValue={list.name} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`desc-${list.id}`}>Description</Label>
<textarea
id={`desc-${list.id}`}
name="description"
defaultValue={list.description ?? ""}
rows={2}
className="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" variant="default">
Save
</Button>
<Button
type="submit"
formAction={deleteAccessListAction.bind(null, list.id)}
variant="outlined"
color="error"
variant="outline"
className="text-destructive hover:text-destructive"
>
Delete list
</Button>
</Box>
</Stack>
</div>
</form>
<Divider sx={{ my: 1 }} />
<Separator />
<Stack spacing={1.5}>
<Typography fontWeight={600}>Accounts</Typography>
<div className="flex flex-col gap-2">
<p className="font-semibold">Accounts</p>
{list.entries.length === 0 ? (
<Typography color="text.secondary">No credentials configured.</Typography>
<p className="text-sm text-muted-foreground">No credentials configured.</p>
) : (
<List dense disablePadding>
<div className="flex flex-col gap-2">
{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>
<div
key={entry.id}
className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2"
>
<div>
<p className="text-sm font-medium">{entry.username}</p>
<p className="text-xs text-muted-foreground">
Created {new Date(entry.created_at).toLocaleDateString()}
</p>
</div>
<form action={deleteAccessEntryAction.bind(null, list.id, entry.id)}>
<Button type="submit" variant="ghost" size="icon" className="h-8 w-8 text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</form>
</div>
))}
</List>
</div>
)}
</Stack>
</div>
<Divider sx={{ my: 1 }} />
<Separator />
<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>
<form
action={(formData) => addAccessEntryAction(list.id, formData)}
className="flex flex-col sm:flex-row gap-2 items-end"
>
<div className="flex flex-col gap-1.5 w-full">
<Label htmlFor={`username-${list.id}`}>Username</Label>
<Input id={`username-${list.id}`} name="username" required />
</div>
<div className="flex flex-col gap-1.5 w-full">
<Label htmlFor={`password-${list.id}`}>Password</Label>
<Input id={`password-${list.id}`} name="password" type="password" required />
</div>
<Button type="submit" className="shrink-0">Add</Button>
</form>
</CardContent>
</Card>
))}
</Stack>
</div>
{pageCount > 1 && (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<Pagination
count={pageCount}
page={pagination.page}
onChange={handlePageChange}
color="primary"
shape="rounded"
/>
</Box>
<div className="flex justify-center gap-2 mt-2">
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant={page === pagination.page ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page)}
>
{page}
</Button>
))}
</div>
)}
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create access list
</Typography>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Create access list</h2>
<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 className="pt-6">
<form action={createAccessListAction} className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-name">Name</Label>
<Input id="create-name" name="name" placeholder="Internal users" required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-description">Description</Label>
<textarea
id="create-description"
name="description"
placeholder="Optional description"
rows={2}
className="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-users">Seed members</Label>
<textarea
id="create-users"
name="users"
rows={3}
placeholder="One per line, username:password"
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
<p className="text-xs text-muted-foreground">One per line, username:password</p>
</div>
<div className="flex justify-end">
<Button type="submit">Create Access List</Button>
</div>
</form>
</CardContent>
</Card>
</Stack>
</Stack>
</section>
</div>
);
}

View File

@@ -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) => (
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: "nowrap" }}>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(r.created_at).toLocaleString()}
</Typography>
</span>
),
},
{
@@ -68,53 +69,44 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
label: "User",
width: 160,
render: (r: EventRow) => (
<Chip label={r.user} size="small" variant="outlined" />
<Badge variant="outline">{r.user}</Badge>
),
},
{
id: "summary",
label: "Event",
render: (r: EventRow) => (
<Typography variant="body2">{r.summary}</Typography>
<p className="text-sm">{r.summary}</p>
),
},
];
const mobileCard = (r: EventRow) => (
<Card variant="outlined" sx={{ p: 2 }}>
<Stack spacing={0.5}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Chip label={r.user} size="small" variant="outlined" />
<Typography variant="caption" color="text.secondary">
<Card>
<CardContent className="p-3 flex flex-col gap-1">
<div className="flex justify-between items-center">
<Badge variant="outline">{r.user}</Badge>
<span className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString()}
</Typography>
</Stack>
<Typography variant="body2">{r.summary}</Typography>
</Stack>
</span>
</div>
<p className="text-sm">{r.summary}</p>
</CardContent>
</Card>
);
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>
<div className="flex flex-col gap-4 w-full">
<h1 className="text-2xl font-bold tracking-tight">Audit Log</h1>
<p className="text-sm text-muted-foreground">Review configuration changes and user activity.</p>
<TextField
placeholder="Search audit log..."
<SearchField
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
updateSearch(e.target.value);
}}
slotProps={{
input: {
startAdornment: <SearchIcon sx={{ mr: 1, color: "rgba(255, 255, 255, 0.5)" }} />,
},
}}
size="small"
sx={{ maxWidth: 400 }}
placeholder="Search audit log..."
/>
<DataTable
@@ -125,6 +117,6 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
pagination={pagination}
mobileCard={mobileCard}
/>
</Stack>
</div>
);
}

View File

@@ -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 (
<Stack spacing={3} sx={{ width: "100%" }}>
<div className="flex flex-col gap-4 w-full">
{/* Page header */}
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
SSL/TLS Certificates
</Typography>
<Typography color="text.secondary">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">SSL/TLS Certificates</h1>
<p className="text-sm text-muted-foreground">
Caddy automatically handles HTTPS certificates for all proxy hosts using Let&apos;s Encrypt.
Import custom certificates only when needed (internal CA, special requirements, etc.).
</Typography>
</Stack>
</p>
</div>
{/* Status summary bar */}
<StatusSummaryBar
@@ -81,26 +80,8 @@ export default function CertificatesClient({
onFilter={setStatusFilter}
/>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tab
label={`ACME (${acmePagination.total})`}
value="acme"
/>
<Tab
label={`Imported (${importedCerts.length})`}
value="imported"
/>
<Tab
label={`CA / mTLS (${caCertificates.length})`}
value="ca"
/>
</Tabs>
</Box>
{/* Per-tab search */}
<TextField
<Input
placeholder={
activeTab === "acme"
? "Search by host name or domain…"
@@ -110,35 +91,42 @@ export default function CertificatesClient({
}
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ maxWidth: 400 }}
inputProps={{ "aria-label": "search" }}
className="max-w-sm"
aria-label="search"
/>
{/* Tab panels */}
{activeTab === "acme" && (
<AcmeTab
acmeHosts={acmeHosts}
acmePagination={acmePagination}
search={searchAcme}
statusFilter={statusFilter}
/>
)}
{activeTab === "imported" && (
<ImportedTab
importedCerts={importedCerts}
managedCerts={managedCerts}
search={searchImported}
statusFilter={statusFilter}
/>
)}
{activeTab === "ca" && (
<CaTab
caCertificates={caCertificates}
search={searchCa}
statusFilter={statusFilter}
/>
)}
</Stack>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList>
<TabsTrigger value="acme">ACME ({acmePagination.total})</TabsTrigger>
<TabsTrigger value="imported">Imported ({importedCerts.length})</TabsTrigger>
<TabsTrigger value="ca">CA / mTLS ({caCertificates.length})</TabsTrigger>
</TabsList>
<TabsContent value="acme" className="mt-4">
<AcmeTab
acmeHosts={acmeHosts}
acmePagination={acmePagination}
search={searchAcme}
statusFilter={statusFilter}
/>
</TabsContent>
<TabsContent value="imported" className="mt-4">
<ImportedTab
importedCerts={importedCerts}
managedCerts={managedCerts}
search={searchImported}
statusFilter={statusFilter}
/>
</TabsContent>
<TabsContent value="ca" className="mt-4">
<CaTab
caCertificates={caCertificates}
search={searchCa}
statusFilter={statusFilter}
/>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -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<StatCard[]> {
const accessListsCount = accessListCountResult[0]?.value ?? 0;
return [
{ label: "Proxy Hosts", icon: <SwapHorizIcon fontSize="large" />, count: proxyHostsCount, href: "/proxy-hosts" },
{ label: "Certificates", icon: <SecurityIcon fontSize="large" />, count: certificatesCount, href: "/certificates" },
{ label: "Access Lists", icon: <VpnKeyIcon fontSize="large" />, count: accessListsCount, href: "/access-lists" }
{ label: "Proxy Hosts", icon: <ArrowLeftRight className="h-8 w-8" />, count: proxyHostsCount, href: "/proxy-hosts" },
{ label: "Certificates", icon: <ShieldCheck className="h-8 w-8" />, count: certificatesCount, href: "/certificates" },
{ label: "Access Lists", icon: <KeyRound className="h-8 w-8" />, count: accessListsCount, href: "/access-lists" }
];
}

View File

@@ -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 (
<Box>
<Typography variant="h4" gutterBottom>
Profile & Account Settings
</Typography>
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold tracking-tight">Profile & Account Settings</h1>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
<Alert variant="destructive">
<AlertDescription className="flex justify-between items-center">
{error}
<Button variant="ghost" size="sm" onClick={() => setError(null)} className="h-auto p-0 text-xs">Dismiss</Button>
</AlertDescription>
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 3 }} onClose={() => setSuccess(null)}>
{success}
<Alert>
<AlertDescription className="flex justify-between items-center">
{success}
<Button variant="ghost" size="sm" onClick={() => setSuccess(null)} className="h-auto p-0 text-xs">Dismiss</Button>
</AlertDescription>
</Alert>
)}
<Stack spacing={3}>
<div className="flex flex-col gap-4">
{/* Account Information */}
<Card>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<PersonIcon color="primary" />
<Typography variant="h6">Account Information</Typography>
</Box>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Account Information</h2>
</div>
<Divider />
<Separator />
{/* Avatar Section */}
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Profile Picture
</Typography>
<Box display="flex" alignItems="center" gap={2}>
<Avatar
src={avatarUrl || undefined}
alt={user.name || user.email}
sx={{ width: 80, height: 80 }}
>
{/* Avatar Section */}
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Profile Picture</p>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={avatarUrl || undefined} alt={user.name || user.email} />
<AvatarFallback className="text-2xl">
{(!avatarUrl && user.name) ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
</Avatar>
<Box display="flex" gap={1}>
<Button
variant="outlined"
component="label"
startIcon={<PhotoCamera />}
disabled={loading}
>
</AvatarFallback>
</Avatar>
<div className="flex gap-2">
<Button variant="outline" asChild disabled={loading}>
<label className="cursor-pointer">
<Camera className="h-4 w-4 mr-2" />
Upload
<input
type="file"
hidden
className="hidden"
accept="image/*"
onChange={handleAvatarUpload}
/>
</label>
</Button>
{avatarUrl && (
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={handleAvatarDelete}
disabled={loading}
>
<Trash2 className="h-4 w-4" />
</Button>
{avatarUrl && (
<IconButton
color="error"
onClick={handleAvatarDelete}
disabled={loading}
>
<DeleteIcon />
</IconButton>
)}
</Box>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Recommended: Square image, max 2MB
</Typography>
</Box>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">Recommended: Square image, max 2MB</p>
</div>
<Divider />
<Separator />
<Box>
<Typography variant="body2" color="text.secondary">
Email
</Typography>
<Typography variant="body1">{user.email}</Typography>
</Box>
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="text-sm">{user.email}</p>
</div>
<Box>
<Typography variant="body2" color="text.secondary">
Name
</Typography>
<Typography variant="body1">{user.name || "Not set"}</Typography>
</Box>
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="text-sm">{user.name || "Not set"}</p>
</div>
<Box>
<Typography variant="body2" color="text.secondary">
Role
</Typography>
<Chip label={user.role} size="small" color="primary" />
</Box>
<div>
<p className="text-sm text-muted-foreground">Role</p>
<Badge>{user.role}</Badge>
</div>
<Box>
<Typography variant="body2" color="text.secondary">
Authentication Method
</Typography>
<Chip
label={getProviderName(user.provider)}
size="small"
color={getProviderColor(user.provider)}
/>
</Box>
<div>
<p className="text-sm text-muted-foreground">Authentication Method</p>
<Badge variant={user.provider === "credentials" ? "secondary" : "default"}>
{getProviderName(user.provider)}
</Badge>
</div>
{hasPassword && (
<Box>
<Typography variant="body2" color="text.secondary">
Password
</Typography>
<Typography variant="body1" color="success.main">
Password is set
</Typography>
</Box>
)}
</Stack>
{hasPassword && (
<div>
<p className="text-sm text-muted-foreground">Password</p>
<p className="text-sm text-green-600 dark:text-green-400">&#10003; Password is set</p>
</div>
)}
</CardContent>
</Card>
{/* Password Management */}
<Card>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<LockIcon color="primary" />
<Typography variant="h6">Password Management</Typography>
</Box>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<Lock className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Password Management</h2>
</div>
<Divider />
<Separator />
{hasPassword ? (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Change your password to maintain account security
</Typography>
<Button
variant="outlined"
onClick={() => setPasswordDialogOpen(true)}
sx={{ mt: 1 }}
>
Change Password
</Button>
</Box>
) : (
<Box>
<Alert severity="warning" sx={{ mb: 2 }}>
{hasPassword ? (
<div>
<p className="text-sm text-muted-foreground mb-2">Change your password to maintain account security</p>
<Button variant="outline" onClick={() => setPasswordDialogOpen(true)}>
Change Password
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
<Alert className="border-yellow-500/50 text-yellow-700 dark:text-yellow-400">
<AlertDescription>
You are using OAuth-only authentication. Setting a password will allow you to
sign in with either OAuth or credentials.
</Alert>
<Button
variant="contained"
onClick={() => setPasswordDialogOpen(true)}
>
Set Password
</Button>
</Box>
)}
</Stack>
</AlertDescription>
</Alert>
<Button onClick={() => setPasswordDialogOpen(true)}>
Set Password
</Button>
</div>
)}
</CardContent>
</Card>
{/* OAuth Management */}
{enabledProviders.length > 0 && (
<Card>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<LinkIcon color="primary" />
<Typography variant="h6">OAuth Connections</Typography>
</Box>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<Link className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">OAuth Connections</h2>
</div>
<Divider />
<Separator />
{hasOAuth ? (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Your account is linked to {getProviderName(user.provider)}
</Typography>
{hasOAuth ? (
<div>
<p className="text-sm text-muted-foreground mb-2">
Your account is linked to {getProviderName(user.provider)}
</p>
{hasPassword ? (
{hasPassword ? (
<Button
variant="outline"
className="text-yellow-600 border-yellow-600/50"
onClick={() => setUnlinkDialogOpen(true)}
>
<Unlink className="h-4 w-4 mr-2" />
Unlink OAuth Account
</Button>
) : (
<Alert className="border-blue-500/50 text-blue-700 dark:text-blue-400">
<AlertDescription>
To unlink OAuth, you must first set a password as a fallback authentication method.
</AlertDescription>
</Alert>
)}
</div>
) : (
<div>
<p className="text-sm text-muted-foreground mb-3">
Link an OAuth provider to enable single sign-on
</p>
<div className="flex flex-col gap-2">
{enabledProviders.map((provider) => (
<Button
variant="outlined"
color="warning"
startIcon={<LinkOffIcon />}
onClick={() => setUnlinkDialogOpen(true)}
sx={{ mt: 1 }}
key={provider.id}
variant="outline"
onClick={() => handleLinkOAuth(provider.id)}
className="w-full"
>
Unlink OAuth Account
<LogIn className="h-4 w-4 mr-2" />
Link {provider.name}
</Button>
) : (
<Alert severity="info" sx={{ mt: 1 }}>
To unlink OAuth, you must first set a password as a fallback authentication
method.
</Alert>
)}
</Box>
) : (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Link an OAuth provider to enable single sign-on
</Typography>
<Stack spacing={1} sx={{ mt: 2 }}>
{enabledProviders.map((provider) => (
<Button
key={provider.id}
variant="outlined"
startIcon={<LoginIcon />}
onClick={() => handleLinkOAuth(provider.id)}
fullWidth
>
Link {provider.name}
</Button>
))}
</Stack>
</Box>
)}
</Stack>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</Stack>
</div>
{/* Change Password Dialog */}
<Dialog open={passwordDialogOpen} onClose={() => setPasswordDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{hasPassword ? "Change Password" : "Set Password"}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Dialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{hasPassword ? "Change Password" : "Set Password"}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 mt-2">
{hasPassword && (
<TextField
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
fullWidth
autoComplete="current-password"
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="currentPassword">Current Password</Label>
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
)}
<TextField
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
fullWidth
autoComplete="new-password"
helperText="Minimum 12 characters"
/>
<TextField
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
fullWidth
autoComplete="new-password"
/>
</Stack>
<div className="flex flex-col gap-1.5">
<Label htmlFor="newPassword">New Password</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">Minimum 12 characters</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPasswordDialogOpen(false)}>Cancel</Button>
<Button onClick={handlePasswordChange} disabled={loading}>
{loading ? "Saving..." : hasPassword ? "Change Password" : "Set Password"}
</Button>
</DialogFooter>
</DialogContent>
<DialogActions>
<Button onClick={() => setPasswordDialogOpen(false)}>Cancel</Button>
<Button onClick={handlePasswordChange} variant="contained" disabled={loading}>
{loading ? "Saving..." : hasPassword ? "Change Password" : "Set Password"}
</Button>
</DialogActions>
</Dialog>
{/* Unlink OAuth Dialog */}
<Dialog open={unlinkDialogOpen} onClose={() => setUnlinkDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Unlink OAuth Account</DialogTitle>
<DialogContent>
<DialogContentText>
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.
</DialogContentText>
<Dialog open={unlinkDialogOpen} onOpenChange={setUnlinkDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Unlink OAuth Account</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUnlinkDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleUnlinkOAuth}
className="text-yellow-600 border-yellow-600/50"
variant="outline"
disabled={loading}
>
{loading ? "Unlinking..." : "Unlink OAuth"}
</Button>
</DialogFooter>
</DialogContent>
<DialogActions>
<Button onClick={() => setUnlinkDialogOpen(false)}>Cancel</Button>
<Button onClick={handleUnlinkOAuth} variant="contained" color="warning" disabled={loading}>
{loading ? "Unlinking..." : "Unlink OAuth"}
</Button>
</DialogActions>
</Dialog>
</Box>
</div>
);
}

View File

@@ -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) => (
<Stack>
<Typography variant="body2" fontWeight={600}>
{host.name}
</Typography>
</Stack>
<p className="text-sm font-semibold">{host.name}</p>
)
},
{
id: "domains",
label: "Domains",
render: (host: ProxyHost) => (
<Stack>
<Typography variant="body2" color="text.secondary">
{host.domains[0]}
{host.domains.length > 1 && ` +${host.domains.length - 1} more`}
</Typography>
</Stack>
<p className="text-sm text-muted-foreground">
{host.domains[0]}
{host.domains.length > 1 && ` +${host.domains.length - 1} more`}
</p>
)
},
{
id: "upstreams",
label: "Target",
render: (host: ProxyHost) => (
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
<p className="text-sm text-muted-foreground font-mono">
{host.upstreams[0]}
{host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
</Typography>
</p>
)
},
{
@@ -102,80 +97,120 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
align: "right" as const,
width: 150,
render: (host: ProxyHost) => (
<Stack direction="row" spacing={1} justifyContent="flex-end" alignItems="center">
<div className="flex items-center gap-1 justify-end">
<Switch
checked={host.enabled}
onChange={(e) => handleToggleEnabled(host.id, e.target.checked)}
size="small"
color="success"
onCheckedChange={(checked) => handleToggleEnabled(host.id, checked)}
/>
<Tooltip title="Duplicate">
<IconButton
size="small"
onClick={() => {
setDuplicateHost(host);
setCreateOpen(true);
}}
color="info"
>
<ContentCopyIcon fontSize="small" />
</IconButton>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-400"
onClick={() => {
setDuplicateHost(host);
setCreateOpen(true);
}}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate</TooltipContent>
</Tooltip>
<Tooltip title="Edit">
<IconButton size="small" onClick={() => setEditHost(host)} color="primary">
<EditIcon fontSize="small" />
</IconButton>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditHost(host)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" onClick={() => setDeleteHost(host)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteHost(host)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</Stack>
</div>
)
}
];
const mobileCard = (host: ProxyHost) => (
<Card variant="outlined" sx={{ p: 2 }}>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="subtitle2" fontWeight={700}>
{host.name}
</Typography>
<Stack direction="row" spacing={0.5} alignItems="center">
<Card className="p-4">
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center">
<p className="text-sm font-bold">{host.name}</p>
<div className="flex items-center gap-0.5">
<Switch
checked={host.enabled}
onChange={(e) => handleToggleEnabled(host.id, e.target.checked)}
size="small"
color="success"
onCheckedChange={(checked) => handleToggleEnabled(host.id, checked)}
/>
<Tooltip title="Duplicate">
<IconButton size="small" onClick={() => { setDuplicateHost(host); setCreateOpen(true); }} color="info">
<ContentCopyIcon fontSize="small" />
</IconButton>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-blue-400"
onClick={() => { setDuplicateHost(host); setCreateOpen(true); }}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate</TooltipContent>
</Tooltip>
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit" onClick={() => setEditHost(host)} color="primary">
<EditIcon fontSize="small" />
</IconButton>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Edit"
onClick={() => setEditHost(host)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" aria-label="Delete" onClick={() => setDeleteHost(host)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
aria-label="Delete"
onClick={() => setDeleteHost(host)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</Stack>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: "monospace", fontSize: "0.75rem" }}>
</div>
</div>
<p className="text-xs text-muted-foreground font-mono">
{host.domains[0]}{host.domains.length > 1 ? ` +${host.domains.length - 1}` : ""} {host.upstreams[0]}{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
</Typography>
</Stack>
</p>
</div>
</Card>
);
return (
<Stack spacing={4}>
<div className="flex flex-col gap-6">
<PageHeader
title="Proxy Hosts"
description="Define HTTP(S) reverse proxies orchestrated by Caddy with automated certificates."
@@ -232,6 +267,6 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
onClose={() => setDeleteHost(null)}
/>
)}
</Stack>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
<TooltipProvider>
{children}
</TooltipProvider>
<Toaster richColors position="bottom-right" />
</ThemeProvider>
);