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:
@@ -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}>
|
||||||
|
|||||||
@@ -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'; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
227
web/src/components/advanced/CertificatesSubsystem.tsx
Normal file
227
web/src/components/advanced/CertificatesSubsystem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
10
web/src/router/pages/advanced/CertificatesPage.tsx
Normal file
10
web/src/router/pages/advanced/CertificatesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { CertificatesSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function CertificatesPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<CertificatesSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user