feat: move acme certificate automation into a plugin (C7)

This commit is contained in:
LukeGus committed 2026-09-24 21:19:27 -05:00
1 parent 4dbce08379
commit 8c1e02af89
123 files changed
+4808 -1912

No files matched your search

+10 -79
View File
@@ -49,12 +49,10 @@ import {
type BrandingSettings,
} from "@/api/settings-api";
import {
getAcmeSslSettings,
updateAcmeSslSettings,
requestAcmeCertificate,
uploadManualSslCertificate,
type AcmeSettings,
} from "@/api/acme-ssl-api";
getTlsStatus,
uploadTlsCertificate,
type TlsStatus,
} from "@/api/tls-api";
import {
type ApiKey,
type CreatedApiKey,
@@ -193,21 +191,7 @@ export function AdminSettingsPanel({
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
// ACME SSL state
const defaultAcmeSettings: AcmeSettings = {
enabled: false,
domain: "",
email: "",
challengeType: "http-webroot",
cloudflareToken: "",
lastIssuedAt: null,
certStatus: "none",
certExpiresAt: null,
};
const [acmeSettings, setAcmeSettings] =
useState<AcmeSettings>(defaultAcmeSettings);
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
const [acmeRequesting, setAcmeRequesting] = useState(false);
const [tlsStatus, setTlsStatus] = useState<TlsStatus | null>(null);
const [manualCertDraft, setManualCertDraft] = useState("");
const [manualKeyDraft, setManualKeyDraft] = useState("");
const [manualUploading, setManualUploading] = useState(false);
@@ -369,8 +353,8 @@ export function AdminSettingsPanel({
.then((d) => setHostDefaults(d))
.catch(() => {});
getAcmeSslSettings()
.then((s) => setAcmeSettings(s))
getTlsStatus()
.then((s) => setTlsStatus(s))
.catch(() => {});
}
@@ -556,53 +540,6 @@ export function AdminSettingsPanel({
}
}
async function handleSaveAcmeSettings() {
try {
const payload: Parameters<typeof updateAcmeSslSettings>[0] = {
enabled: acmeSettings.enabled,
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
...(cloudflareTokenDraft && { cloudflareToken: cloudflareTokenDraft }),
};
const updated = await updateAcmeSslSettings(payload);
setAcmeSettings(updated);
setCloudflareTokenDraft("");
toast.success(t("admin.sslSaved"));
} catch {
toast.error(t("admin.sslSaveFailed"));
}
}
async function handleRequestAcmeCertificate() {
if (!acmeSettings.domain || !acmeSettings.email) {
toast.error(t("admin.sslRequiresDomain"));
return;
}
setAcmeRequesting(true);
try {
if (cloudflareTokenDraft) {
await updateAcmeSslSettings({
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
cloudflareToken: cloudflareTokenDraft,
});
setCloudflareTokenDraft("");
}
const result = await requestAcmeCertificate();
setAcmeSettings(result);
toast.success(t("admin.sslRequestCertSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed")));
} finally {
setAcmeRequesting(false);
}
}
async function handleManualSslUpload() {
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
toast.error(t("admin.sslManualRequiresFields"));
@@ -610,11 +547,11 @@ export function AdminSettingsPanel({
}
setManualUploading(true);
try {
const result = await uploadManualSslCertificate({
const result = await uploadTlsCertificate({
certificate: manualCertDraft,
privateKey: manualKeyDraft,
});
setAcmeSettings(result);
setTlsStatus(result);
setManualCertDraft("");
setManualKeyDraft("");
toast.success(t("admin.sslManualUploadSuccess"));
@@ -997,13 +934,7 @@ export function AdminSettingsPanel({
<AdminSSLSection
open={openSections.has("ssl")}
onToggle={() => toggle("ssl")}
settings={acmeSettings}
setSettings={setAcmeSettings}
cloudflareTokenDraft={cloudflareTokenDraft}
setCloudflareTokenDraft={setCloudflareTokenDraft}
requesting={acmeRequesting}
handleSave={handleSaveAcmeSettings}
handleRequest={handleRequestAcmeCertificate}
status={tlsStatus}
manualCertDraft={manualCertDraft}
setManualCertDraft={setManualCertDraft}
manualKeyDraft={manualKeyDraft}
+93 -177
View File
@@ -4,11 +4,10 @@ import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { SettingRow } from "@/components/section-card";
import { Select2 } from "@/components/select2";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import type { HostDefaults } from "@/api/settings-api";
import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
import type { TlsStatus } from "@/api/tls-api";
type GeneralSettingsSectionProps = {
open: boolean;
@@ -541,7 +540,18 @@ export function AdminHostDefaultsSection({
);
}
const CERT_STATUS_STYLES: Record<AcmeSettings["certStatus"], string> = {
function certState(
status: TlsStatus | null,
): "none" | "valid" | "expiring" | "expired" {
const cert = status?.certificate;
if (!cert) return "none";
const left = new Date(cert.notAfter).getTime() - Date.now();
if (left <= 0) return "expired";
if (left < 30 * 86_400_000) return "expiring";
return "valid";
}
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-yellow-500",
@@ -551,13 +561,7 @@ const CERT_STATUS_STYLES: Record<AcmeSettings["certStatus"], string> = {
type AdminSSLSectionProps = {
open: boolean;
onToggle: () => void;
settings: AcmeSettings;
setSettings: Dispatch<SetStateAction<AcmeSettings>>;
cloudflareTokenDraft: string;
setCloudflareTokenDraft: Dispatch<SetStateAction<string>>;
requesting: boolean;
handleSave: () => void;
handleRequest: () => void;
status: TlsStatus | null;
manualCertDraft: string;
setManualCertDraft: Dispatch<SetStateAction<string>>;
manualKeyDraft: string;
@@ -569,13 +573,7 @@ type AdminSSLSectionProps = {
export function AdminSSLSection({
open,
onToggle,
settings,
setSettings,
cloudflareTokenDraft,
setCloudflareTokenDraft,
requesting,
handleSave,
handleRequest,
status,
manualCertDraft,
setManualCertDraft,
manualKeyDraft,
@@ -584,8 +582,11 @@ export function AdminSSLSection({
handleManualUpload,
}: AdminSSLSectionProps) {
const { t } = useTranslation();
const cert = status?.certificate ?? null;
const state = certState(status);
const expiry = cert ? new Date(cert.notAfter).toLocaleDateString() : "";
const certStatusLabel: Record<AcmeSettings["certStatus"], string> = {
const certStatusLabel: Record<ReturnType<typeof certState>, string> = {
none: t("admin.sslCertStatusNone"),
valid: t("admin.sslCertStatusValid"),
expiring: t("admin.sslCertStatusExpiring"),
@@ -618,182 +619,97 @@ export function AdminSSLSection({
{t("admin.sslCertStatus")}
</span>
<span
className={`text-xs font-medium ${CERT_STATUS_STYLES[settings.certStatus]}`}
className={`text-xs font-medium ${CERT_STATUS_STYLES[state]}`}
>
{certStatusLabel[settings.certStatus]}
{certStatusLabel[state]}
</span>
</div>
{settings.certExpiresAt && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", {
date: new Date(settings.certExpiresAt).toLocaleDateString(),
})}
</span>
)}
{settings.lastIssuedAt && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslLastIssued", {
date: new Date(settings.lastIssuedAt).toLocaleString(),
})}
</span>
{cert && (
<>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslNames", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslIssuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", { date: expiry })}
</span>
{cert.selfSigned && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslSelfSigned")}
</span>
)}
{status?.renewal && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslRenewedBy", {
plugin: status.renewal.pluginName,
})}
</span>
)}
</>
)}
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-yellow-500/40 bg-yellow-500/10 text-[10px] text-yellow-600 dark:text-yellow-400">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest border-t border-border pt-2">
{t("admin.sslManualTitle")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualCert")}
</label>
<textarea
rows={5}
value={manualCertDraft}
onChange={(e) => setManualCertDraft(e.target.value)}
placeholder={t("admin.sslManualCertPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslChallengeType")}
{t("admin.sslManualKey")}
</label>
<Select2
value={settings.challengeType}
onChange={(event) =>
setSettings((p) => ({
...p,
challengeType: event.target.value as AcmeChallengeType,
}))
}
className="w-full text-xs h-8"
>
<option value="http-webroot">HTTP (webroot)</option>
<option value="dns-cloudflare">DNS (Cloudflare)</option>
<option value="manual">{t("admin.sslManualOption")}</option>
</Select2>
<textarea
rows={5}
value={manualKeyDraft}
onChange={(e) => setManualKeyDraft(e.target.value)}
placeholder={t("admin.sslManualKeyPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslChallengeTypeDesc")}
{t("admin.sslManualDesc")}
</span>
</div>
{settings.challengeType !== "manual" && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslDomain")}
</label>
<Input
value={settings.domain}
onChange={(e) =>
setSettings((p) => ({ ...p, domain: e.target.value }))
}
placeholder={t("admin.sslDomainPlaceholder")}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslEmail")}
</label>
<Input
value={settings.email}
onChange={(e) =>
setSettings((p) => ({ ...p, email: e.target.value }))
}
placeholder={t("admin.sslEmailPlaceholder")}
className="text-xs"
/>
</div>
</>
)}
{settings.challengeType === "dns-cloudflare" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslCloudflareToken")}
</label>
<PasswordInput
value={cloudflareTokenDraft}
onChange={(e) => setCloudflareTokenDraft(e.target.value)}
placeholder={
settings.cloudflareToken ||
t("admin.sslCloudflareTokenPlaceholder")
}
className="text-xs h-8 pr-8"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCloudflareTokenDesc")}
</span>
</div>
)}
{settings.challengeType === "manual" && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualCert")}
</label>
<textarea
rows={5}
value={manualCertDraft}
onChange={(e) => setManualCertDraft(e.target.value)}
placeholder={t("admin.sslManualCertPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualKey")}
</label>
<textarea
rows={5}
value={manualKeyDraft}
onChange={(e) => setManualKeyDraft(e.target.value)}
placeholder={t("admin.sslManualKeyPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslManualDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleManualUpload}
disabled={manualUploading}
>
<RefreshCw
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
/>
{manualUploading
? t("admin.sslManualUploadLoading")
: t("admin.sslManualUpload")}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleManualUpload}
disabled={manualUploading}
>
<RefreshCw
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
/>
{manualUploading
? t("admin.sslManualUploadLoading")
: t("admin.sslManualUpload")}
</Button>
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
{t("admin.sslInfoNote")}
</span>
{settings.challengeType !== "manual" && (
<div className="flex flex-col gap-1.5">
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSave}
>
{t("admin.sslSave")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleRequest}
disabled={requesting}
>
<RefreshCw
className={`size-3 ${requesting ? "animate-spin" : ""}`}
/>
{requesting
? t("admin.sslRequestCertLoading")
: t("admin.sslRequestCert")}
</Button>
</div>
)}
</div>
</AccordionSection>
);