import { useMemo, useState } from "react"; import { ShieldCheck, RefreshCw, Plus, X, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/button"; import { managerPost } from "@/main-axios"; import { useManagerData, useManagerAction, extractError, } from "./useManagerData"; import { ManagerCardShell } from "./ManagerCardShell"; import { Select2 } from "@/components/select2"; interface CertInfo { client: string; name: string; domains: string[]; expiry: string | null; } interface SslData { clients: { certbot: boolean; acmeSh: boolean }; certs: CertInfo[]; } type Challenge = "http-standalone" | "http-webroot" | "dns"; /** Days until the given expiry string, or null if unparseable. */ function daysUntil(expiry: string | null): number | null { if (!expiry) return null; const ts = Date.parse(expiry); if (Number.isNaN(ts)) return null; return Math.round((ts - Date.now()) / 86400000); } function ExpiryBadge({ expiry }: { expiry: string | null }) { const { t } = useTranslation(); const days = daysUntil(expiry); if (days == null) return {expiry ?? "—"}; const tone = days < 0 ? "text-destructive" : days < 14 ? "text-yellow-500" : "text-accent-brand"; return ( {days < 0 ? t("hostMetrics.managers.sslExpired") : t("hostMetrics.managers.sslInDays", { days })} ); } export function SslManagerCard({ hostId }: { hostId: number | null }) { const { t } = useTranslation(); const { data, loading, error, refresh } = useManagerData( hostId, "ssl", ); const { busy, run } = useManagerAction(hostId); const [showIssue, setShowIssue] = useState(false); const [domains, setDomains] = useState(""); const [challenge, setChallenge] = useState("http-standalone"); const [webroot, setWebroot] = useState(""); const [dnsProvider, setDnsProvider] = useState(""); const [issuing, setIssuing] = useState(false); const clients = data?.clients; const activeClient: "certbot" | "acme.sh" | null = clients?.certbot ? "certbot" : clients?.acmeSh ? "acme.sh" : null; const certs = useMemo(() => data?.certs ?? [], [data?.certs]); const renew = (dryRun: boolean) => { if (!activeClient) return; run( "ssl", { client: activeClient, dryRun }, { action: "renew", toastId: "ssl-op", loadingMsg: t("hostMetrics.managers.working"), successMsg: t("hostMetrics.managers.actionDone", { name: "renew" }), failMsg: t("hostMetrics.managers.actionFailed"), onDone: refresh, }, ); }; const revoke = (cert: CertInfo) => { if (cert.client !== "certbot" && cert.client !== "acme.sh") return; if ( !window.confirm( t("hostMetrics.managers.sslRevokeConfirm", { name: cert.name }), ) ) return; run( "ssl", { client: cert.client, name: cert.name }, { action: "revoke", toastId: "ssl-op", loadingMsg: t("hostMetrics.managers.working"), successMsg: t("hostMetrics.managers.sslRevoked"), failMsg: t("hostMetrics.managers.actionFailed"), onDone: refresh, }, ); }; const issue = async () => { if (hostId == null || !activeClient) return; const list = domains .split(/[\s,]+/) .map((d) => d.trim()) .filter(Boolean); if (list.length === 0) { const { toast } = await import("sonner"); toast.error(t("hostMetrics.managers.sslNeedDomain")); return; } setIssuing(true); const { toast } = await import("sonner"); toast.loading(t("hostMetrics.managers.working"), { id: "ssl-issue" }); try { const res = await managerPost<{ success: boolean; output: string }>( hostId, "ssl", { client: activeClient, domains: list, challenge, webroot: challenge === "http-webroot" ? webroot : undefined, dnsProvider: challenge === "dns" ? dnsProvider : undefined, }, "issue", ); toast[res.success ? "success" : "error"]( res.success ? t("hostMetrics.managers.sslIssued") : t("hostMetrics.managers.actionFailed"), { id: "ssl-issue", description: res.output?.slice(-200) }, ); if (res.success) { setShowIssue(false); setDomains(""); refresh(); } } catch (e) { toast.error(extractError(e).message, { id: "ssl-issue" }); } finally { setIssuing(false); } }; return ( } loading={loading} error={error} onRefresh={refresh} empty={!loading && certs.length === 0 && !activeClient} emptyMessage={t("hostMetrics.managers.noAcmeClient")} headerExtra={ activeClient ? ( <> ) : undefined } >
{t("hostMetrics.managers.clients")}: certbot acme.sh
{activeClient && ( )}
{showIssue && activeClient && (
setDomains(e.target.value)} placeholder={t("hostMetrics.managers.sslDomainsPlaceholder")} className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" />
setChallenge(e.target.value as Challenge)} className="h-7 flex-1 border border-border bg-background px-1 text-xs" >
{challenge === "http-webroot" && ( setWebroot(e.target.value)} placeholder="/var/www/html" className="h-7 w-full border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring" /> )} {challenge === "dns" && ( setDnsProvider(e.target.value)} placeholder={t("hostMetrics.managers.sslDnsProvider")} className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" /> )} {t("hostMetrics.managers.sslIssueHint")}
)}
{certs.map((c) => (
{c.name}
{(c.client === "certbot" || c.client === "acme.sh") && ( )}
))}
); }