- 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>
205 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|