feat: Enhanced cert status API + TLS certificates UI

API changes:
- /api/v1/cert/status now returns all relevant certs (central,
  wildcard, per-service) with existence status and expiry
- /api/v1/cert/provision accepts ?domain= param for per-domain
  provisioning
- Removed stale syncHAProxy references, uses reconcileNetworking

UI changes:
- Added TLS Certificates card to Cloudflare page showing cert status
  for each domain with provision/renew actions
- Updated useCert hook and cert API types for new response format

Next: extract into dedicated Certificates page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:23:08 +00:00
parent 7d5e4f4e29
commit 9eea061e9b
4 changed files with 239 additions and 80 deletions

View File

@@ -20,7 +20,7 @@ export function CloudflareComponent() {
const { verification, isLoading: isVerifying, refetch: reverify } = useCloudflare();
const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig();
const { status: ddnsStatus } = useDdns();
const { status: certStatus, isLoading: certLoading, provision: provisionCert, isProvisioning, provisionError } = useCert();
const { status: certStatus, isLoading: certLoading, provision: provisionCert, isProvisioning, provisionError, renew: renewCerts, isRenewing } = useCert();
const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({
@@ -182,7 +182,7 @@ export function CloudflareComponent() {
<div className="font-medium">Central Domain</div>
</div>
<div className="flex items-center gap-2">
{certStatus?.cert?.exists && (
{(certStatus?.certs?.some(c => c.cert.exists) || certStatus?.cert?.exists) && (
<Badge variant="success" className="gap-1">
<Shield className="h-3 w-3" />
TLS
@@ -256,52 +256,126 @@ export function CloudflareComponent() {
)}
</div>
{globalConfig?.cloud?.central?.domain && (
<div className="ml-7 space-y-2">
{certLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : certStatus?.cert?.exists ? (
<div className="text-sm space-y-1">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<Shield className="h-4 w-4" />
<span>Valid TLS certificate</span>
{globalConfig?.cloud?.central?.domain && (() => {
const centralCert = certStatus?.certs?.find(c => c.type === 'central') ?? (certStatus?.cert ? { domain: certStatus.domain ?? '', type: 'central' as const, cert: certStatus.cert } : undefined);
return (
<div className="ml-7 space-y-2">
{certLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : centralCert?.cert.exists ? (
<div className="text-sm space-y-1">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<Shield className="h-4 w-4" />
<span>Valid TLS certificate</span>
</div>
<div className="text-xs text-muted-foreground">
Expires in {centralCert.cert.daysLeft} days
{centralCert.cert.issuerCN && <> &middot; {centralCert.cert.issuerCN}</>}
</div>
</div>
<div className="text-xs text-muted-foreground">
Expires in {certStatus.cert.daysLeft} days
{certStatus.cert.issuerCN && <> &middot; {certStatus.cert.issuerCN}</>}
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 text-sm">
<KeyRound className="h-4 w-4" />
<span>No TLS certificate</span>
</div>
<Button
size="sm"
onClick={async () => {
try { await provisionCert(centralCert?.domain); } catch { /* error shown below */ }
}}
disabled={isProvisioning}
className="gap-1"
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Shield className="h-3 w-3" />}
{isProvisioning ? 'Provisioning...' : 'Provision TLS Certificate'}
</Button>
{provisionError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{(provisionError as Error).message}</AlertDescription>
</Alert>
)}
</div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 text-sm">
<KeyRound className="h-4 w-4" />
<span>No TLS certificate</span>
</div>
<Button
size="sm"
onClick={async () => {
try { await provisionCert(); } catch { /* error shown below */ }
}}
disabled={isProvisioning}
className="gap-1"
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Shield className="h-3 w-3" />}
{isProvisioning ? 'Provisioning...' : 'Provision TLS Certificate'}
</Button>
{provisionError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{(provisionError as Error).message}</AlertDescription>
</Alert>
)}
</div>
)}
</div>
)}
)}
</div>
);
})()}
</div>
)}
</Card>
{/* TLS Certificates Card */}
{certStatus?.certs && certStatus.certs.length > 0 && (
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<div className="font-medium">TLS Certificates</div>
</div>
</div>
<div className="space-y-3 ml-7">
{certStatus.certs.map((entry) => (
<div key={entry.domain} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="font-mono">{entry.domain}</span>
<Badge variant="outline" className="text-xs">{entry.type}</Badge>
</div>
<div className="flex items-center gap-2">
{entry.cert.exists ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Valid ({entry.cert.daysLeft}d)
</Badge>
) : (
<>
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Missing
</Badge>
{certStatus.canProvision && (
<Button
variant="outline"
size="sm"
onClick={async () => {
try { await provisionCert(entry.domain); } catch { /* error shown in alerts */ }
}}
disabled={isProvisioning}
className="gap-1 h-7 text-xs"
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Shield className="h-3 w-3" />}
Provision
</Button>
)}
</>
)}
</div>
</div>
))}
{provisionError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{(provisionError as Error).message}</AlertDescription>
</Alert>
)}
<div className="pt-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try { await renewCerts(); } catch { /* error shown in alerts */ }
}}
disabled={isRenewing}
className="gap-1"
>
{isRenewing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
{isRenewing ? 'Renewing...' : 'Renew All'}
</Button>
</div>
</div>
</Card>
)}
{/* Verification Checklist Card */}
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
@@ -399,15 +473,22 @@ export function CloudflareComponent() {
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<span>TLS Certificate</span>
<span>TLS Certificates</span>
</div>
{certStatus?.cert?.exists
? <Badge variant="success" className="text-xs">
Valid ({certStatus.cert.daysLeft}d left)
</Badge>
: certStatus?.configured
? <Badge variant="secondary" className="text-xs">Not provisioned</Badge>
: <Badge variant="secondary" className="text-xs">Not configured</Badge>}
{(() => {
const certs = certStatus?.certs;
const allExist = certs && certs.length > 0 && certs.every(c => c.cert.exists);
const someExist = certs && certs.some(c => c.cert.exists);
if (allExist) {
return <Badge variant="success" className="text-xs">All valid</Badge>;
} else if (someExist) {
return <Badge variant="warning" className="text-xs">Partial</Badge>;
} else if (certStatus?.configured) {
return <Badge variant="secondary" className="text-xs">Not provisioned</Badge>;
} else {
return <Badge variant="secondary" className="text-xs">Not configured</Badge>;
}
})()}
</div>
{/* ExternalDNS note */}