Add TLS Certificates page to Advanced section of web UI

New page at /advanced/certificates showing:
- Overview card with central domain, cert count, Cloudflare token status
- Auto-provision readiness badge (requires CF token + operator email)
- Table of all domains needing TLS with: domain, source, expiry date,
  issuer, and status badges (days left / covered by wildcard / missing)
- Per-domain "Provision" button for missing certs when auto-provision ready
- "Provision" and "Renew All" action buttons
- Guidance alert when prerequisites are missing

Also fixes pre-existing type errors:
- AppSidebar: guard daemon index access when undefined
- DomainsComponent: fix circular type reference in filter state
- DomainTopology: remove unused isWildcard variable
This commit is contained in:
2026-07-14 12:08:43 +00:00
parent a85bfbfe9e
commit 947991da7f
8 changed files with 247 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react'; import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { import {
Sidebar, Sidebar,
@@ -105,6 +105,7 @@ export function AppSidebar() {
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const }, { to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
{ to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined }, { to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined },
{ to: '/advanced/certificates', icon: ShieldCheck, label: 'Certificates', daemon: undefined },
]; ];
return ( return (
@@ -203,7 +204,7 @@ export function AppSidebar() {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{advancedItems.map(({ to, icon: Icon, label, daemon }) => { {advancedItems.map(({ to, icon: Icon, label, daemon }) => {
const active = centralStatus?.daemons?.[daemon]?.active; const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
return ( return (
<SidebarMenuItem key={to}> <SidebarMenuItem key={to}>
<NavLink to={to}> <NavLink to={to}>

View File

@@ -53,10 +53,11 @@ export function DomainsComponent() {
const [search, setSearch] = useState(() => { const [search, setSearch] = useState(() => {
try { return localStorage.getItem('domains:search') ?? ''; } catch { return ''; } try { return localStorage.getItem('domains:search') ?? ''; } catch { return ''; }
}); });
const [filter, setFilter] = useState<'all' | 'public' | 'private' | 'direct' | 'l4' | 'l7'>(() => { type FilterType = 'all' | 'public' | 'private' | 'direct' | 'l4' | 'l7';
const [filter, setFilter] = useState<FilterType>(() => {
try { try {
const v = localStorage.getItem('domains:filter'); const v = localStorage.getItem('domains:filter');
return (v as typeof filter) || 'all'; return (v as FilterType) || 'all';
} catch { return 'all'; } } catch { return 'all'; }
}); });

View File

@@ -0,0 +1,227 @@
import { useState } from 'react';
import { Card, CardContent } from '../ui/card';
import { Button } from '../ui/button';
import { Alert, AlertDescription } from '../ui/alert';
import { Badge } from '../ui/badge';
import { Loader2, CheckCircle, AlertCircle, RefreshCw, ShieldCheck, Clock, AlertTriangle } from 'lucide-react';
import { useCert } from '../../hooks/useCert';
function formatDate(iso?: string): string {
if (!iso) return '--';
const d = new Date(iso);
if (isNaN(d.getTime())) return '--';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function daysLeftBadge(daysLeft?: number) {
if (daysLeft === undefined) return null;
if (daysLeft <= 7) {
return <Badge variant="destructive" className="gap-1 text-xs"><AlertTriangle className="h-3 w-3" />Expires in {daysLeft}d</Badge>;
}
if (daysLeft <= 30) {
return <Badge variant="secondary" className="gap-1 text-xs"><Clock className="h-3 w-3" />{daysLeft}d left</Badge>;
}
return <Badge variant="success" className="gap-1 text-xs"><CheckCircle className="h-3 w-3" />{daysLeft}d left</Badge>;
}
export function CertificatesSubsystem() {
const { status, isLoading, provision, isProvisioning, renew, isRenewing } = useCert();
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const certs = status?.certs ?? [];
const hasCerts = certs.some(c => c.cert.exists);
async function handleProvision(domain?: string) {
setMessage(null);
try {
const result = await provision(domain);
setMessage({ type: 'success', text: result.message });
setTimeout(() => setMessage(null), 5000);
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Provisioning failed' });
setTimeout(() => setMessage(null), 8000);
}
}
async function handleRenew() {
setMessage(null);
try {
const result = await renew();
setMessage({ type: 'success', text: result.message });
setTimeout(() => setMessage(null), 5000);
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Renewal failed' });
setTimeout(() => setMessage(null), 8000);
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldCheck className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
<p className="text-muted-foreground">Manage TLS certificates for domain routing</p>
</div>
</div>
{status && (
<Badge variant={status.canProvision ? 'success' : 'secondary'} className="gap-1">
{status.canProvision ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{status.canProvision ? 'Auto-provision ready' : 'Manual only'}
</Badge>
)}
</div>
{/* Alerts */}
{message && (
<Alert variant={message.type === 'success' ? 'default' : 'error'}>
{message.type === 'success' ? <CheckCircle className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
{!status?.canProvision && status && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{!status.hasToken && !status.hasEmail
? 'Configure a Cloudflare API token and operator email to enable automatic certificate provisioning.'
: !status.hasToken
? 'Configure a Cloudflare API token to enable automatic certificate provisioning.'
: 'Configure an operator email to enable automatic certificate provisioning.'}
</AlertDescription>
</Alert>
)}
{/* Status + Actions */}
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm flex-1">
<div>
<span className="text-muted-foreground">Central Domain</span>
<div className="font-mono mt-0.5">{status?.domain ?? '--'}</div>
</div>
<div>
<span className="text-muted-foreground">Certificates</span>
<div className="mt-0.5">{certs.filter(c => c.cert.exists).length} active / {certs.length} domains</div>
</div>
<div>
<span className="text-muted-foreground">Cloudflare Token</span>
<div className="mt-0.5">{status?.hasToken ? 'Configured' : 'Not configured'}</div>
</div>
</div>
<div className="flex gap-2 ml-4 shrink-0">
{status?.canProvision && (
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => handleProvision()}
disabled={isProvisioning || !status?.domain}
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <ShieldCheck className="h-3 w-3" />}
Provision
</Button>
)}
{hasCerts && (
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={handleRenew}
disabled={isRenewing}
>
{isRenewing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Renew All
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Certificates table */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-3">Domain Certificates</h3>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
</div>
) : certs.length === 0 ? (
<div className="text-center py-8">
<ShieldCheck className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
No domains require TLS certificates. Register an HTTP domain to get started.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 font-medium text-muted-foreground">Domain</th>
<th className="pb-2 font-medium text-muted-foreground">Source</th>
<th className="pb-2 font-medium text-muted-foreground">Expires</th>
<th className="pb-2 font-medium text-muted-foreground">Issuer</th>
<th className="pb-2 font-medium text-muted-foreground text-right">Status</th>
</tr>
</thead>
<tbody>
{certs.map((entry) => (
<tr key={entry.domain} className="border-b last:border-0">
<td className="py-2 font-mono text-xs">
{entry.domain}
{entry.cert.wildcard && <span className="text-muted-foreground ml-1">(wildcard)</span>}
</td>
<td className="py-2 text-xs text-muted-foreground">{entry.source}</td>
<td className="py-2 text-xs">
{entry.cert.exists ? formatDate(entry.cert.expiry) : '--'}
</td>
<td className="py-2 text-xs text-muted-foreground truncate max-w-[200px]" title={entry.cert.issuerCN}>
{entry.cert.issuerCN ?? '--'}
</td>
<td className="py-2 text-right">
{entry.cert.exists ? (
daysLeftBadge(entry.cert.daysLeft)
) : entry.coveredBy ? (
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle className="h-3 w-3" />
{entry.coveredBy}
</Badge>
) : (
<span className="inline-flex items-center gap-2">
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
Missing
</Badge>
{status?.canProvision && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleProvision(entry.domain)}
disabled={isProvisioning}
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Provision'}
</Button>
)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -5,3 +5,4 @@ export { WireguardSubsystem } from './WireguardSubsystem';
export { CrowdsecSubsystem } from './CrowdsecSubsystem'; export { CrowdsecSubsystem } from './CrowdsecSubsystem';
export { AutheliaSubsystem } from './AutheliaSubsystem'; export { AutheliaSubsystem } from './AutheliaSubsystem';
export { DdnsSubsystem } from './DdnsSubsystem'; export { DdnsSubsystem } from './DdnsSubsystem';
export { CertificatesSubsystem } from './CertificatesSubsystem';

View File

@@ -227,7 +227,6 @@ function FlowNode({ data }: NodeProps) {
if (v === 'tls') { if (v === 'tls') {
const status = data.status as NodeStatus; const status = data.status as NodeStatus;
const certDomain = data.certDomain as string | undefined; const certDomain = data.certDomain as string | undefined;
const isWildcard = data.isWildcardCert as boolean;
const daysLeft = data.daysLeft as number; const daysLeft = data.daysLeft as number;
const hasCert = data.hasCert as boolean; const hasCert = data.hasCert as boolean;
const provisionDomain = data.provisionDomain as string; const provisionDomain = data.provisionDomain as string;

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { CertificatesSubsystem } from '../../../components/advanced';
export function CertificatesPage() {
return (
<ErrorBoundary>
<CertificatesSubsystem />
</ErrorBoundary>
);
}

View File

@@ -5,3 +5,4 @@ export { WireguardPage } from './WireguardPage';
export { CrowdsecPage } from './CrowdsecPage'; export { CrowdsecPage } from './CrowdsecPage';
export { AutheliaAdvancedPage } from './AutheliaPage'; export { AutheliaAdvancedPage } from './AutheliaPage';
export { DdnsPage } from './DdnsPage'; export { DdnsPage } from './DdnsPage';
export { CertificatesPage } from './CertificatesPage';

View File

@@ -14,6 +14,7 @@ import {
CrowdsecPage as CrowdsecAdvancedPage, CrowdsecPage as CrowdsecAdvancedPage,
AutheliaAdvancedPage, AutheliaAdvancedPage,
DdnsPage, DdnsPage,
CertificatesPage,
} from './pages/advanced'; } from './pages/advanced';
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
@@ -36,6 +37,7 @@ export const routes: RouteObject[] = [
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> }, { path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
{ path: 'advanced/authelia', element: <AutheliaAdvancedPage /> }, { path: 'advanced/authelia', element: <AutheliaAdvancedPage /> },
{ path: 'advanced/ddns', element: <DdnsPage /> }, { path: 'advanced/ddns', element: <DdnsPage /> },
{ path: 'advanced/certificates', element: <CertificatesPage /> },
], ],
}, },
{ {