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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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">✓ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user