Files
wild-central/web/src/router/pages/CertificatesPage.tsx
Paul Payne 56b7dd800d feat: Update web UI for domain-keyed service model
- RegisteredService: removed name, added subdomains bool
- CertEntry: removed service field (domain is the key)
- Ingress page: domain as primary label, subdomains badge
- Certificates page: source shown under domain
- All type-check passes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 20:39:38 +00:00

205 lines
8.4 KiB
TypeScript

import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input, Label } from '../../components/ui';
import { Alert, AlertDescription } from '../../components/ui/alert';
import { Badge } from '../../components/ui/badge';
import { Shield, Loader2, CheckCircle, AlertCircle, RefreshCw, Plus, ShieldCheck, ShieldAlert, X } from 'lucide-react';
import { useCert } from '../../hooks/useCert';
import { usePageHelp } from '../../hooks/usePageHelp';
export function CertificatesPage() {
const {
status: certStatus,
isLoading,
provision: provisionCert,
isProvisioning,
renew: renewCerts,
isRenewing,
} = useCert();
const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null);
const [showWildcardForm, setShowWildcardForm] = useState(false);
const [wildcardInput, setWildcardInput] = useState('');
usePageHelp({
title: 'TLS Certificates',
description: (
<p className="leading-relaxed">
Wild Central provisions TLS certificates for services that need HTTPS.
Each service gets its own certificate by default. You can also provision
a wildcard certificate to cover multiple services under the same domain.
</p>
),
});
const certs = certStatus?.certs ?? [];
const canProvision = certStatus?.canProvision ?? false;
const handleProvision = async (domain: string) => {
setProvisioningDomain(domain);
try {
await provisionCert(domain);
} finally {
setProvisioningDomain(null);
}
};
const handleProvisionWildcard = async () => {
if (!wildcardInput) return;
const domain = wildcardInput.startsWith('*.') ? wildcardInput : `*.${wildcardInput}`;
setProvisioningDomain(domain);
try {
await provisionCert(domain);
setShowWildcardForm(false);
setWildcardInput('');
} finally {
setProvisioningDomain(null);
}
};
if (isLoading) {
return (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading certificate status...</p>
</Card>
);
}
const allExist = certs.length > 0 && certs.every((c) => c.cert?.exists || c.coveredBy);
const someExist = certs.some((c) => c.cert?.exists || c.coveredBy);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Shield className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
<p className="text-muted-foreground">
Manage HTTPS certificates for registered services
</p>
</div>
</div>
{allExist ? (
<Badge variant="success" className="gap-1"><ShieldCheck className="h-3 w-3" />All Valid</Badge>
) : someExist ? (
<Badge variant="warning" className="gap-1"><ShieldAlert className="h-3 w-3" />Incomplete</Badge>
) : certs.length > 0 ? (
<Badge variant="destructive" className="gap-1"><ShieldAlert className="h-3 w-3" />Missing</Badge>
) : null}
</div>
{!canProvision && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Certificate provisioning requires a Cloudflare API token and operator email.
{!certStatus?.hasToken && ' Configure the token on the Cloudflare page.'}
{!certStatus?.hasEmail && ' Set the operator email in Overview.'}
</AlertDescription>
</Alert>
)}
{certs.length === 0 ? (
<Card className="p-8 text-center">
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Services Registered</h3>
<p className="text-muted-foreground">
Register services with Wild Central to manage their certificates here.
</p>
</Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Service Certificates</CardTitle>
<div className="flex gap-2">
{canProvision && (
<Button variant="outline" size="sm" onClick={() => setShowWildcardForm(true)}>
<Plus className="h-4 w-4 mr-1" />Wildcard
</Button>
)}
<Button variant="outline" size="sm" onClick={() => renewCerts()} disabled={isRenewing || !someExist}>
{isRenewing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RefreshCw className="h-4 w-4 mr-1" />}
Renew All
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{showWildcardForm && (
<div className="p-3 border rounded-lg bg-muted/50 space-y-2">
<Label>Provision Wildcard Certificate</Label>
<div className="flex gap-2">
<div className="flex items-center gap-1 flex-1">
<span className="text-sm text-muted-foreground">*.</span>
<Input
value={wildcardInput}
onChange={(e) => setWildcardInput(e.target.value)}
placeholder="cloud.payne.io"
className="font-mono"
/>
</div>
<Button size="sm" onClick={handleProvisionWildcard} disabled={isProvisioning || !wildcardInput}>
{provisioningDomain?.startsWith('*.') ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
Provision
</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowWildcardForm(false); setWildcardInput(''); }}>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
A wildcard cert covers all subdomains. E.g., *.cloud.payne.io covers app1.cloud.payne.io, app2.cloud.payne.io, etc.
</p>
</div>
)}
<div className="border rounded-lg divide-y">
{certs.map((entry) => {
const exists = entry.cert?.exists;
const covered = entry.coveredBy;
const isThisProvisioning = provisioningDomain === entry.domain;
return (
<div key={entry.domain} className="px-4 py-3 flex items-center justify-between">
<div className="min-w-0">
<div className="font-mono text-sm truncate">{entry.domain}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{entry.source}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-4">
{exists ? (
<Badge variant="success" className="gap-1">
<ShieldCheck className="h-3 w-3" />{entry.cert.daysLeft}d
</Badge>
) : covered ? (
<Badge variant="outline" className="gap-1 text-green-600 border-green-300">
<CheckCircle className="h-3 w-3" />{covered}
</Badge>
) : (
<>
<Badge variant="destructive" className="gap-1">Missing</Badge>
{canProvision && (
<Button size="sm" variant="outline" onClick={() => handleProvision(entry.domain)} disabled={isProvisioning}>
{isThisProvisioning ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
Provision
</Button>
)}
</>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
);
}