diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index 8e31d67..58b7e78 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; 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 { Sidebar, @@ -105,6 +105,7 @@ export function AppSidebar() { { to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const }, { to: '/advanced/ddns', icon: Globe, label: 'DDNS', daemon: undefined }, + { to: '/advanced/certificates', icon: ShieldCheck, label: 'Certificates', daemon: undefined }, ]; return ( @@ -203,7 +204,7 @@ export function AppSidebar() { {advancedItems.map(({ to, icon: Icon, label, daemon }) => { - const active = centralStatus?.daemons?.[daemon]?.active; + const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined; return ( diff --git a/web/src/components/DomainsComponent.tsx b/web/src/components/DomainsComponent.tsx index 46e30df..dda980a 100644 --- a/web/src/components/DomainsComponent.tsx +++ b/web/src/components/DomainsComponent.tsx @@ -53,10 +53,11 @@ export function DomainsComponent() { const [search, setSearch] = useState(() => { 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(() => { try { const v = localStorage.getItem('domains:filter'); - return (v as typeof filter) || 'all'; + return (v as FilterType) || 'all'; } catch { return 'all'; } }); diff --git a/web/src/components/advanced/CertificatesSubsystem.tsx b/web/src/components/advanced/CertificatesSubsystem.tsx new file mode 100644 index 0000000..a8039d6 --- /dev/null +++ b/web/src/components/advanced/CertificatesSubsystem.tsx @@ -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 Expires in {daysLeft}d; + } + if (daysLeft <= 30) { + return {daysLeft}d left; + } + return {daysLeft}d left; +} + +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 ( +
+ {/* Header */} +
+
+
+ +
+
+

TLS Certificates

+

Manage TLS certificates for domain routing

+
+
+ {status && ( + + {status.canProvision ? : } + {status.canProvision ? 'Auto-provision ready' : 'Manual only'} + + )} +
+ + {/* Alerts */} + {message && ( + + {message.type === 'success' ? : } + {message.text} + + )} + + {!status?.canProvision && status && ( + + + + {!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.'} + + + )} + + {/* Status + Actions */} + + +
+
+
+ Central Domain +
{status?.domain ?? '--'}
+
+
+ Certificates +
{certs.filter(c => c.cert.exists).length} active / {certs.length} domains
+
+
+ Cloudflare Token +
{status?.hasToken ? 'Configured' : 'Not configured'}
+
+
+
+ {status?.canProvision && ( + + )} + {hasCerts && ( + + )} +
+
+
+
+ + {/* Certificates table */} + + +

Domain Certificates

+ {isLoading ? ( +
+ +
+ ) : certs.length === 0 ? ( +
+ +

+ No domains require TLS certificates. Register an HTTP domain to get started. +

+
+ ) : ( +
+ + + + + + + + + + + + {certs.map((entry) => ( + + + + + + + + ))} + +
DomainSourceExpiresIssuerStatus
+ {entry.domain} + {entry.cert.wildcard && (wildcard)} + {entry.source} + {entry.cert.exists ? formatDate(entry.cert.expiry) : '--'} + + {entry.cert.issuerCN ?? '--'} + + {entry.cert.exists ? ( + daysLeftBadge(entry.cert.daysLeft) + ) : entry.coveredBy ? ( + + + {entry.coveredBy} + + ) : ( + + + + Missing + + {status?.canProvision && ( + + )} + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/web/src/components/advanced/index.ts b/web/src/components/advanced/index.ts index 468a36b..76c93fc 100644 --- a/web/src/components/advanced/index.ts +++ b/web/src/components/advanced/index.ts @@ -5,3 +5,4 @@ export { WireguardSubsystem } from './WireguardSubsystem'; export { CrowdsecSubsystem } from './CrowdsecSubsystem'; export { AutheliaSubsystem } from './AutheliaSubsystem'; export { DdnsSubsystem } from './DdnsSubsystem'; +export { CertificatesSubsystem } from './CertificatesSubsystem'; diff --git a/web/src/components/domains/DomainTopology.tsx b/web/src/components/domains/DomainTopology.tsx index f60091a..060aae0 100644 --- a/web/src/components/domains/DomainTopology.tsx +++ b/web/src/components/domains/DomainTopology.tsx @@ -227,7 +227,6 @@ function FlowNode({ data }: NodeProps) { if (v === 'tls') { const status = data.status as NodeStatus; const certDomain = data.certDomain as string | undefined; - const isWildcard = data.isWildcardCert as boolean; const daysLeft = data.daysLeft as number; const hasCert = data.hasCert as boolean; const provisionDomain = data.provisionDomain as string; diff --git a/web/src/router/pages/advanced/CertificatesPage.tsx b/web/src/router/pages/advanced/CertificatesPage.tsx new file mode 100644 index 0000000..4fd90aa --- /dev/null +++ b/web/src/router/pages/advanced/CertificatesPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../../components'; +import { CertificatesSubsystem } from '../../../components/advanced'; + +export function CertificatesPage() { + return ( + + + + ); +} diff --git a/web/src/router/pages/advanced/index.ts b/web/src/router/pages/advanced/index.ts index 23b6111..ec6e512 100644 --- a/web/src/router/pages/advanced/index.ts +++ b/web/src/router/pages/advanced/index.ts @@ -5,3 +5,4 @@ export { WireguardPage } from './WireguardPage'; export { CrowdsecPage } from './CrowdsecPage'; export { AutheliaAdvancedPage } from './AutheliaPage'; export { DdnsPage } from './DdnsPage'; +export { CertificatesPage } from './CertificatesPage'; diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index be6007d..97a9093 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -14,6 +14,7 @@ import { CrowdsecPage as CrowdsecAdvancedPage, AutheliaAdvancedPage, DdnsPage, + CertificatesPage, } from './pages/advanced'; export const routes: RouteObject[] = [ @@ -36,6 +37,7 @@ export const routes: RouteObject[] = [ { path: 'advanced/crowdsec', element: }, { path: 'advanced/authelia', element: }, { path: 'advanced/ddns', element: }, + { path: 'advanced/certificates', element: }, ], }, {