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:
@@ -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 && <> · {centralCert.cert.issuerCN}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Expires in {certStatus.cert.daysLeft} days
|
||||
{certStatus.cert.issuerCN && <> · {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 */}
|
||||
|
||||
Reference in New Issue
Block a user